From 315cafcbd462b8f3f5f46f9c24213820f972b454 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 10:51:02 -0400 Subject: [PATCH 01/23] Split scouting routes into feature modules app/scout/routes.py (1451 lines) becomes a package on the same "scouting" blueprint: records, pit, lighthouse, leaderboards, events, plus common (blueprint init and shared helpers). URLs, endpoint names and methods are unchanged. - SCOUTER_LOOKUP / team_access_match replace eight copies of the scouter join and team access filter - actor_name replaces the repeated username-or-Anonymous expression - fuel_total / _alliance_fuel replace hand-written fuel sums - drop unused tba global, format_team_stats, commented check_team Co-Authored-By: Claude Opus 5.5 --- app/scout/routes.py | 1451 ---------------------------- app/scout/routes/__init__.py | 5 + app/scout/routes/common.py | 69 ++ app/scout/routes/events.py | 182 ++++ app/scout/routes/leaderboards.py | 317 ++++++ app/scout/routes/lighthouse.py | 303 ++++++ app/scout/routes/pit.py | 167 ++++ app/scout/routes/records.py | 318 ++++++ tests/test_match13_fuel.py | 10 +- tests/test_scout_routes_common.py | 33 + tests/test_scouting_pagination.py | 8 +- tests/test_scouting_performance.py | 2 +- 12 files changed, 1404 insertions(+), 1461 deletions(-) delete mode 100644 app/scout/routes.py create mode 100644 app/scout/routes/__init__.py create mode 100644 app/scout/routes/common.py create mode 100644 app/scout/routes/events.py create mode 100644 app/scout/routes/leaderboards.py create mode 100644 app/scout/routes/lighthouse.py create mode 100644 app/scout/routes/pit.py create mode 100644 app/scout/routes/records.py create mode 100644 tests/test_scout_routes_common.py diff --git a/app/scout/routes.py b/app/scout/routes.py deleted file mode 100644 index 9ea1cd1..0000000 --- a/app/scout/routes.py +++ /dev/null @@ -1,1451 +0,0 @@ -from __future__ import annotations - -import json -import logging -import os -from datetime import datetime, timezone - -import aiohttp -from bson import ObjectId, json_util -from flask import (Blueprint, current_app, flash, jsonify, redirect, - render_template, request, url_for) -from flask_login import current_user, login_required - -from app.scout.scouting_utils import ScoutingManager -from app.utils import async_route, handle_route_errors, limiter - -from .TBA import TBAInterface -from .match13_fuel import FUEL_FIELDS, FuelEstimateService, zero_scored_fuel - -scouting_bp = Blueprint("scouting", __name__) -scouting_manager = None -tba = None - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -@scouting_bp.record -def on_blueprint_init(state): - global scouting_manager, tba - app = state.app - - # Create ScoutingManager with the singleton connection - scouting_manager = ScoutingManager(app.config["MONGO_URI"]) - - # Initialize TBA - tba = TBAInterface(api_key=app.config.get("TBA_KEY", "")) - - # Store in app context for proper cleanup - if not hasattr(app, 'db_managers'): - app.db_managers = {} - app.db_managers['scouting'] = scouting_manager - try: - service = FuelEstimateService( - scouting_manager.db, os.getenv("MATCH13_STATE_PATH") or os.path.join(app.instance_path, "match13.sqlite"), - api_key=os.getenv("MATCH_13_API_KEY") or os.getenv("MATCH13_API_KEY", ""), - tba_key=os.getenv("TBA_AUTH_KEY", "")) - app.extensions["match13_fuel"] = service - service.start() - except Exception: - app.logger.exception("Fuel estimates unavailable; normal scouting is unaffected") - - - -@scouting_bp.route("/scouting/add", methods=["GET", "POST"]) -@login_required -# @limiter.limit("15 per minute") -@handle_route_errors -def add(): - if request.method != "POST": - # Get current events only - tba = TBAInterface() - year = datetime.now().year - events = tba.get_current_events(year, include_test_data=True, background=True) or {} - - return render_template("scouting/add.html", - events=events, - event_matches={}) # Empty dict - - data = request.get_json() if request.is_json else request.form.to_dict() - - if "auto_path" in data: - try: - if isinstance(data["auto_path"], str): - if data["auto_path"].strip(): - data["auto_path"] = json.loads(data["auto_path"]) - else: - data["auto_path"] = [] - except json.JSONDecodeError: - flash("Invalid path coordinates format", "error") - return redirect(url_for("scouting.home")) - - use_estimate = zero_scored_fuel(data) - for field in FUEL_FIELDS: - if data.get(field) in (None, ""): - data[field] = 0 - success, message = scouting_manager.add_scouting_data(data, current_user.get_id()) - service = current_app.extensions.get("match13_fuel") - if success and use_estimate and service: - try: - service.enroll(message, data) - except Exception: - current_app.logger.exception("Saved scouting record; could not enroll its fuel estimate") - current_app.logger.info(f"Tried to add scouting data ({success}) {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'} - {message}") - current_app.logger.info(f"Scouting.add Form Details {request.form} - {message}") - - if success: - flash("Team data added successfully", "success") - else: - flash(f"Error adding data: {message}", "error") - - return redirect(url_for("scouting.home")) - - -SCOUTING_PAGE_SIZE = 20 - - -@scouting_bp.route("/scouting/list") -@scouting_bp.route("/scouting") -# @limiter.limit("30 per minute") -@login_required -def home(): - try: - page = max(1, request.args.get("page", 1, type=int)) if request.args.get("partial") == "1" else 1 - result = scouting_manager.get_all_scouting_data( - current_user.teamNumber, current_user.get_id(), page=page, - page_size=SCOUTING_PAGE_SIZE, search=request.args.get("search", ""), - filter_type=request.args.get("filter", "team")) - team_data, total = result["records"], result["total"] - service = current_app.extensions.get("match13_fuel") - if service: - statuses = service.statuses(record["_id"] for record in team_data) - for record in team_data: - record["fuel_estimate"] = statuses.get(str(record["_id"])) - - # Get the user's team if they have one - team = None - if current_user.teamNumber: - team_query = {"team_number": current_user.teamNumber} - if team_doc := scouting_manager.db.teams.find_one(team_query): - from app.models import Team - team = Team.create_from_db(team_doc) - has_more = page * SCOUTING_PAGE_SIZE < total - if request.args.get("partial") == "1": - return jsonify(html=render_template("scouting/_match_list.html", team_data=team_data, team=team), - total=total, has_more=has_more, next_page=page + 1) - return render_template("scouting/list.html", team_data=team_data, team=team, - total=total, has_more=has_more) - except Exception as e: - current_app.logger.error(f"Error fetching scouting data: {str(e)}", exc_info=True) - flash("Unable to fetch scouting data. Please try again later.", "error") - if request.args.get("partial") == "1": - return jsonify(error="Unable to load matches. Please try again."), 503 - return render_template("scouting/list.html", team_data=[], total=0, has_more=False) - - -@scouting_bp.route("/api/fuel_estimates", methods=["POST"]) -@login_required -def fuel_estimates(): - payload = request.get_json(silent=True) or {} - ids = payload.get("ids") if isinstance(payload, dict) else None - if not isinstance(ids, list) or len(ids) > 100 or any(not isinstance(id, str) or not ObjectId.is_valid(id) for id in ids): - return jsonify(error="Provide up to 100 valid record IDs."), 400 - service = current_app.extensions.get("match13_fuel") - if not service or not ids: - return jsonify({}) - statuses = service.statuses(ids, include_manual=True) - if not statuses: - return jsonify({}) - access = {"scouter._id": ObjectId(current_user.get_id())} - if current_user.teamNumber: - access = {"$or": [access, {"scouter.teamNumber": current_user.teamNumber}]} - records = scouting_manager.db.team_data.aggregate([ - {"$match": {"_id": {"$in": [ObjectId(id) for id in statuses]}}}, - {"$lookup": {"from": "users", "localField": "scouter_id", "foreignField": "_id", "as": "scouter"}}, - {"$unwind": "$scouter"}, {"$match": access}, - {"$project": {"_id": 1, **dict.fromkeys(FUEL_FIELDS, 1)}}, - ]) - return jsonify({str(record["_id"]): {**statuses[str(record["_id"])], - "fuel": {field: record.get(field, 0) for field in FUEL_FIELDS}} for record in records}) - - -@scouting_bp.route("/scouting/edit/", methods=["GET", "POST"]) -# @limiter.limit("15 per minute") -@login_required -def edit(id): - try: - team_data = scouting_manager.get_team_data(id, current_user.get_id()) - - if not team_data: - flash("Team data not found", "error") - return redirect(url_for("scouting.home")) - - # Check if the current user is on the same team as the scouter - current_team = current_user.teamNumber - scouter_team = team_data.scouter_team - - # Allow access only if user is the original scouter or on the same team - if current_user.get_id() != str(team_data.scouter_id) and (not current_team or not scouter_team or str(current_team) != str(scouter_team)): - flash("Access denied: You can only edit scouting data from your own team", "error") - return redirect(url_for("scouting.home")) - - if request.method == "POST": - data = request.form.to_dict() - current_app.logger.info(f"Scouting.edit Form Details {request.form}") - # Add edit tracking - record who made the edit - data['last_edited_by'] = current_user.get_id() - data['last_edited_at'] = datetime.now().isoformat() - - # Convert the drawing coordinates from string to JSON if present - if "auto_path_coords" in data and isinstance(data["auto_path_coords"], str): - try: - json.loads(data["auto_path_coords"]) # Validate JSON - except json.JSONDecodeError: - flash("Invalid path coordinates format", "error") - return render_template("scouting/edit.html", team_data=team_data) - - service = current_app.extensions.get("match13_fuel") - save = lambda: scouting_manager.update_team_data(id, data, current_user.get_id()) - saved = service.manual_update(id, save, data) if service else save() - if saved: - flash("Data updated successfully", "success") - current_app.logger.info(f"Successfully updated scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return redirect(url_for("scouting.home")) - - current_app.logger.info(f"Failed to update scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Unable to update data", "error") - - service = current_app.extensions.get("match13_fuel") - if service: - team_data.fuel_estimate = service.statuses([id]).get(id) - return render_template("scouting/edit.html", team_data=team_data) - except Exception as e: - current_app.logger.error(f"Error in edit_scouting_data: {str(e)}", exc_info=True) - flash("An error occurred while processing your request", "error") - return redirect(url_for("scouting.home")) - - -@scouting_bp.route("/scouting/delete/") -# @limiter.limit("10 per minute") -@login_required -def delete(id): - try: - # Get information about the record before deleting for better error messages - team_data = scouting_manager.get_team_data(id, current_user.get_id()) - if not team_data: - flash("Record not found", "error") - return redirect(url_for("scouting.home")) - - # Attempt to delete - if scouting_manager.delete_team_data(id, current_user.get_id()): - current_app.logger.info(f"Successfully deleted scouting data {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Record deleted successfully", "success") - elif team_data.scouter_id == current_user.get_id(): - flash("Error deleting your record. Please try again.", "error") - elif current_user.teamNumber and team_data.scouter_team == current_user.teamNumber: - # Check if user is team admin using existing database connection - team_query = {"team_number": current_user.teamNumber} - if team_doc := scouting_manager.db.teams.find_one(team_query): - from app.models import Team - team = Team.create_from_db(team_doc) - - if team.is_admin(current_user.get_id()): - # Try to delete again with admin override - if scouting_manager.delete_team_data(id, current_user.get_id(), admin_override=True): - current_app.logger.info(f"Successfully deleted scouting data {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'} (admin)") - flash("Record deleted successfully (admin)", "success") - else: - current_app.logger.info(f"Failed to delete scouting data {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'} (admin)") - flash("Error deleting team member's record. Please try again.", "error") - else: - current_app.logger.info(f"Permission denied: You must be a team admin to delete other members' records {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Permission denied: You must be a team admin to delete other members' records", "error") - else: - current_app.logger.info(f"Permission denied: Team not found {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Permission denied: Team not found", "error") - else: - current_app.logger.info(f"Permission denied: You can only delete records from your own team {id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Permission denied: You can only delete records from your own team", "error") - except Exception as e: - current_app.logger.error(f"Delete error: {str(e)}", exc_info=True) - flash("An internal error has occurred.", "error") - return redirect(url_for("scouting.home")) - - -@scouting_bp.route("/lighthouse") -# @limiter.limit("30 per minute") -@login_required -def lighthouse(): - current_app.logger.info(f"Successfully fetched lighthouse for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("lighthouse.html") - -@scouting_bp.route("/lighthouse/auton") -# @limiter.limit("30 per minute") -@login_required -def auton(): - current_app.logger.info(f"Successfully fetched lighthouse/auton for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("lighthouse/auton.html") - -def format_team_stats(stats): - """Format team stats with calculated totals""" - avg_teleop_fuel = ( - stats.get("avg_transition_fuel", 0) + - stats.get("avg_teleop_shift_1_fuel", 0) + - stats.get("avg_teleop_shift_2_fuel", 0) + - stats.get("avg_teleop_shift_3_fuel", 0) + - stats.get("avg_teleop_shift_4_fuel", 0) - ) - - return { - "matches_played": stats.get("matches_played", 0), - "auto_fuel_avg": stats.get("avg_auto_fuel", 0), - "teleop_fuel_avg": avg_teleop_fuel, - "ferried_fuel_avg": stats.get("avg_ferried_fuel", 0), - "endgame_fuel_avg": stats.get("avg_endgame_fuel", 0), - "climb_level_avg": stats.get("avg_climb_level", 0), - "climb_success_rate": stats.get("climb_success_rate", 0) * 100 - } - - -@scouting_bp.route("/api/compare") -# @limiter.limit("30 per minute") -@login_required -def compare_teams(): - try: - teams = [] - for i in range(1, 4): - if team_num := request.args.get(f'team{i}'): - teams.append(int(team_num)) - - if len(teams) < 2: - return jsonify({"error": "At least 2 teams are required"}), 400 - - teams_data = {} - for team_num in teams: - try: - pipeline = [ - {"$match": {"team_number": team_num}}, - {"$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - }}, - {"$unwind": "$scouter"}, - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }}, - {"$group": { - "_id": "$team_number", - "matches_played": {"$sum": 1}, - - # Fuel Stats - "avg_auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, - "avg_auto_climb": {"$avg": {"$cond": ["$auto_climb", 1, 0]}}, - "avg_transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, - "avg_ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, - "avg_teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, - "avg_teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, - "avg_teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, - "avg_teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, - "avg_endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, - - # Climb Stats - "climb_success_rate": {"$avg": {"$cond": ["$climb_success", 1, 0]}}, - "climb_level_1_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 1]}, 1, 0]}}, - "climb_level_2_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 2]}, 1, 0]}}, - "climb_level_3_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 3]}, 1, 0]}}, - "avg_climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, - - "defense_notes": {"$push": "$defense_notes"}, - "auto_paths": {"$push": { - "path": "$auto_path", - "notes": "$auto_notes", - "match_number": "$match_number" - }}, - "defense_rating": {"$avg": {"$cond": [{"$gt": ["$defense_rating", 0]}, "$defense_rating", None]}}, - "robot_disabled_list": {"$push": "$robot_disabled"}, - "preferred_climb_type": {"$last": "$climb_type"}, - "matches": {"$push": "$$ROOT"} - }}, - {"$match": {"matches_played": {"$gt": 0}}} - ] - - if stats := list( - scouting_manager.db.team_data.aggregate(pipeline) - ): - - # Calculate total teleop average - avg_teleop_total = ( - (stats[0].get("avg_transition_fuel") or 0) + - (stats[0].get("avg_ferried_fuel") or 0) + - (stats[0]["avg_teleop_shift_1_fuel"] or 0) + - (stats[0]["avg_teleop_shift_2_fuel"] or 0) + - (stats[0]["avg_teleop_shift_3_fuel"] or 0) + - (stats[0]["avg_teleop_shift_4_fuel"] or 0) - ) - - normalized_stats = { - # TODO - "auto_scoring": min((stats[0]["avg_auto_fuel"] or 0) / 10, 1), # Assumes ~10 max auto fuel - "teleop_scoring": min(avg_teleop_total / 30, 1), # Assumes ~30 max teleop fuel - "endgame_scoring": min((stats[0]["avg_endgame_fuel"] or 0) / 10, 1), # Assumes ~10 max endgame fuel - "climb_level": min((stats[0]["avg_climb_level"] or 0) / 3, 1), # Max level 3 - "climb_rating": stats[0]["climb_success_rate"], - "defense_rating": (stats[0]["defense_rating"] or 0) / 5 if stats[0].get("defense_rating") is not None else 0 - } - - # Get team info from TBA - team_key = f"frc{team_num}" - team_info = TBAInterface().get_team(team_key) - - teams_data[str(team_num)] = { - "team_number": team_num, - "nickname": team_info.get("nickname", "Unknown"), - "city": team_info.get("city"), - "state_prov": team_info.get("state_prov"), - "country": team_info.get("country"), - "stats": stats[0], - "normalized_stats": normalized_stats, - "matches": stats[0]["matches"] - } - - except Exception as team_error: - current_app.logger.error(f"Error processing team {team_num}: {str(team_error)}", exc_info=True) - - if not teams_data: - return jsonify({"error": "No data available for the selected teams"}), 404 - - current_app.logger.info(f"Successfully fetched team data {teams_data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return json_util.dumps(teams_data) - - except Exception as e: - current_app.logger.error(f"Error in compare_teams: {str(e)}", exc_info=True) - return jsonify({"error": "An error occurred while comparing teams"}), 500 - -@scouting_bp.route("/api/search") -@login_required -# @limiter.limit("30 per minute") -@async_route -async def search_teams(): - query = request.args.get("q", "").strip() - if not query: - return jsonify([]) - - try: - # Initialize TBA interface - tba = TBAInterface() - - # Handle both numeric and text searches - if query.isdigit(): - team_key = f"frc{query}" - 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([]) - team = await response.json() - else: - # Search by team name/nickname - url = f"{tba.base_url}/teams/search/{query}" - async with aiohttp.ClientSession(headers=tba.headers) as session: - async with session.get(url) as response: - if response.status != 200: - return jsonify([]) - teams = await response.json() - if not teams: - return jsonify([]) - team = teams[0] # Take the first match - - # Get team number from the response - team_number = team.get("team_number") - - # Fetch scouting data from our database - pipeline = [ - {"$match": {"team_number": team_number}}, - {"$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - }}, - {"$unwind": {"path": "$scouter"}}, - # Add team access filter - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }}, - {"$sort": {"event_code": 1, "match_number": 1}}, - { - "$project": { - "_id": {"$toString": "$_id"}, # Convert ObjectId to string - "event_code": 1, - "match_number": 1, - # Fuel Stats - "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, - "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, - "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, - "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, - "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, - "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, - "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, - "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, - - # Climb Stats - "auto_climb": 1, - "climb_level": {"$ifNull": ["$climb_level", 0]}, - "climb_type": 1, - "climb_success": 1, - - "auto_path": 1, - "auto_notes": 1, - "defense_rating": {"$ifNull": ["$defense_rating", 0]}, - "notes": 1, - "scouter_name": "$scouter.username", - "scouter_id": {"$toString": "$scouter._id"} - } - } - ] - - scouting_data = list(scouting_manager.db.team_data.aggregate(pipeline)) - - # Format response - response_data = [{ - "team_number": team_number, - "nickname": team.get("nickname"), - "school_name": team.get("school_name"), - "city": team.get("city"), - "state_prov": team.get("state_prov"), - "country": team.get("country"), - "scouting_data": scouting_data, - "has_team_page": bool(scouting_data) # True if we have any scouting data - }] - - # Use json_util.dumps to handle MongoDB types - current_app.logger.info(f"Successfully fetched team data {response_data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return json_util.dumps(response_data), 200, {'Content-Type': 'application/json'} - - except Exception as e: - current_app.logger.error(f"Error in search_teams: {str(e)}", exc_info=True) - return jsonify({"error": "Failed to fetch team data due to an internal error."}), 500 - -@scouting_bp.route("/leaderboard") -# @limiter.limit("30 per minute") -def leaderboard(): - try: - MIN_MATCHES = 1 - sort_type = request.args.get('sort', 'fuel') - selected_event = request.args.get('event', 'all') - - # Get available events from scouting data - # Filter by team access: only show events from user's team or user himself - events_pipeline = [ - # Join with users collection to get scouter information - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - # Filter by team access - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }}, - # Group by event code to get unique events - {"$group": { - "_id": "$event_code", - "event_name": {"$first": "$event_name"}, - "count": {"$sum": 1} - }}, - {"$sort": {"_id": 1}} - ] - - events = list(scouting_manager.db.team_data.aggregate(events_pipeline)) - - # Main pipeline for team data - pipeline = [ - # Join with users collection to get scouter information - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - # Filter by team access - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }} - ] - - # Filter by selected event if not 'all' - if selected_event != 'all': - pipeline.append({"$match": {"event_code": selected_event}}) - - # Continue with the existing aggregation - pipeline.extend([ - {"$group": { - "_id": "$team_number", - "matches_played": {"$sum": 1}, - - # Auto Fuel - "auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, - "transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, - "ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, - - # Teleop Fuel - "teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, - "teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, - "teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, - "teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, - - # Endgame Fuel - "endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, - - # Climb Level - "auto_climb_sum": {"$sum": {"$cond": [{"$eq": ["$auto_climb", True]}, 1, 0]}}, - "climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, - - # Defense Rating - "defense_rating": {"$avg": {"$ifNull": ["$defense_rating", 0]}}, - # Robot Disabled - "robot_disabled_list": {"$push": "$robot_disabled"}, - - "herd_rating": { - "$avg": { - "$cond": [{"$eq": ["$herd_balls", True]}, {"$ifNull": ["$herd_rating", 0]}, None] - } - }, - "herd_count": {"$sum": {"$cond": [{"$eq": ["$herd_balls", True]}, 1, 0]}}, - - "ferrying_rating": { - "$avg": { - "$cond": [{"$eq": ["$ferrying_did", True]}, {"$ifNull": ["$ferrying_rating", 0]}, None] - } - }, - "ferrying_count": {"$sum": {"$cond": [{"$eq": ["$ferrying_did", True]}, 1, 0]}}, - - # Climb stats - "climb_level_1_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 1]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_level_2_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 2]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_level_3_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 3]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_attempts": {"$sum": 1}, - "climb_successes": { - "$sum": {"$cond": [{"$eq": ["$climb_success", True]}, 1, 0]} - }, - }}, - {"$match": {"matches_played": {"$gte": MIN_MATCHES}}}, - {"$project": { - "team_number": "$_id", - "matches_played": 1, - - "auto_fuel": {"$round": ["$auto_fuel", 1]}, - "transition_fuel": {"$round": ["$transition_fuel", 1]}, - "ferried_fuel": {"$round": ["$ferried_fuel", 1]}, - "teleop_fuel_total": { - "$add": [ - "$transition_fuel", - "$teleop_shift_1_fuel", - "$teleop_shift_2_fuel", - "$teleop_shift_3_fuel", - "$teleop_shift_4_fuel" - ] - }, - "endgame_fuel": {"$round": ["$endgame_fuel", 1]}, - - # Calculate totals for each category - "total_fuel": { - "$add": [ - "$auto_fuel", - "$transition_fuel", - "$teleop_shift_1_fuel", - "$teleop_shift_2_fuel", - "$teleop_shift_3_fuel", - "$teleop_shift_4_fuel", - "$endgame_fuel" - ] - }, - - "climb_level_avg": {"$round": ["$climb_level", 1]}, - - "auto_climb_pct": { - "$multiply": [ - {"$divide": ["$auto_climb_sum", "$matches_played"]}, - 100 - ] - }, - "climb_l1_pct": { - "$multiply": [ - {"$divide": ["$climb_level_1_success", "$matches_played"]}, - 100 - ] - }, - "climb_l2_pct": { - "$multiply": [ - {"$divide": ["$climb_level_2_success", "$matches_played"]}, - 100 - ] - }, - "climb_l3_pct": { - "$multiply": [ - {"$divide": ["$climb_level_3_success", "$matches_played"]}, - 100 - ] - }, - "climb_success_rate": { - "$multiply": [ - {"$cond": [ - {"$gt": ["$climb_attempts", 0]}, - {"$divide": ["$climb_successes", "$climb_attempts"]}, - 0 - ]}, - 100 - ] - }, - "defense_rating": {"$round": ["$defense_rating", 1]}, - "robot_disabled_list": "$robot_disabled_list", - "herd_rating": {"$round": [{"$ifNull": ["$herd_rating", 0]}, 1]}, - "herd_count": 1, - "ferrying_rating": {"$round": [{"$ifNull": ["$ferrying_rating", 0]}, 1]}, - "ferrying_count": 1 - }} - ]) - - # We cannot sort purely by TBA-fetched metrics natively in MongoDB aggregation - # (EPA/OPR/DPR/CCWM require secondary API calls). Let's process those *after* - # the aggregate completes if one of those sorts is requested. - - tba_metrics = {'epa', 'opr', 'dpr', 'ccwm'} - - if sort_type not in tba_metrics: - sort_field = { - 'fuel': 'total_fuel', - 'auto_fuel': 'auto_fuel', - 'ferried_fuel': 'ferried_fuel', - 'auto_climb': 'auto_climb_pct', - 'climb': 'climb_success_rate', - 'climb_l1': 'climb_l1_pct', - 'climb_l2': 'climb_l2_pct', - 'climb_l3': 'climb_l3_pct', - 'defense': 'defense_rating', - 'herd_rating': 'herd_rating', - 'ferrying_rating': 'ferrying_rating' - }.get(sort_type, 'total_fuel') - - pipeline.append({"$sort": {sort_field: -1}}) - - teams = list(scouting_manager.db.team_data.aggregate(pipeline)) - current_app.logger.info(f"Successfully fetched leaderboard {teams} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - - if sort_type in tba_metrics: - from app.scout.team_stats import team_stats_snapshot - snapshots = team_stats_snapshot([ - {"team_number": team["team_number"], "event_code": selected_event} - for team in teams - ], datetime.now().year) - for team in teams: - snapshot = snapshots.get(f"{team['team_number']}_{selected_event}", {}) - team[sort_type] = snapshot.get(sort_type) - teams.sort(key=lambda team: (team.get(sort_type) is None, - (team.get(sort_type) or 0) * - (1 if sort_type == 'dpr' else -1))) - - return render_template("scouting/leaderboard.html", teams=teams, current_sort=sort_type, - events=events, selected_event=selected_event) - except Exception as e: - current_app.logger.error(f"Error in leaderboard: {str(e)}", exc_info=True) - return render_template("scouting/leaderboard.html", teams=[], current_sort='fuel', - events=[], selected_event='all') - -@scouting_bp.route("/scouter-leaderboard") -# @limiter.limit("30 per minute") -@login_required -def scouter_leaderboard(): - try: - sort_by = request.args.get('sort', 'match_count') - selected_event = request.args.get('event', 'all') - selected_team = request.args.get('team', 'all') - - # Get list of events for filtering - events_pipeline = [ - {"$group": {"_id": "$event_code"}}, - {"$sort": {"_id": 1}} - ] - events = [evt["_id"] for evt in scouting_manager.db.team_data.aggregate(events_pipeline)] - - # Build pipeline to count scouting entries by user - pipeline = [ - # Join with users to get scouter information - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - ] - - # Apply event filter if specified - if selected_event != 'all': - pipeline.append({"$match": {"event_code": selected_event}}) - - # Apply team filter if specified - if selected_team != 'all' and selected_team.isdigit(): - pipeline.append({"$match": {"scouter.teamNumber": int(selected_team)}}) - - # Group by scouter and count - pipeline.extend([ - { - "$group": { - "_id": "$scouter._id", - "username": {"$first": "$scouter.username"}, - "teamNumber": {"$first": "$scouter.teamNumber"}, - "match_count": {"$sum": 1}, - "unique_teams": {"$addToSet": "$team_number"}, - } - }, - { - "$project": { - "username": 1, - "teamNumber": 1, - "match_count": 1, - "unique_teams_count": {"$size": "$unique_teams"}, - } - } - ]) - - # Sort by selected field - sort_field = { - 'match_count': 'match_count', - 'unique_teams': 'unique_teams_count', - }.get(sort_by, 'match_count') - - pipeline.append({"$sort": {sort_field: -1}}) - - # Execute query - scouters = list(scouting_manager.db.team_data.aggregate(pipeline)) - - # Get list of all teams for filtering - teams_pipeline = [ - {"$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - }}, - {"$unwind": "$scouter"}, - {"$match": {"scouter.teamNumber": {"$exists": True, "$ne": None}}}, - {"$group": {"_id": "$scouter.teamNumber"}}, - {"$sort": {"_id": 1}} - ] - teams = [team["_id"] for team in scouting_manager.db.team_data.aggregate(teams_pipeline)] - - current_app.logger.info(f"Successfully fetched scouter leaderboard {scouters} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template( - "scouting/scouter-leaderboard.html", - scouters=scouters, - current_sort=sort_by, - events=events, - selected_event=selected_event, - teams=teams, - selected_team=selected_team - ) - except Exception as e: - current_app.logger.error(f"Error fetching scouter leaderboard: {str(e)}", exc_info=True) - return render_template( - "scouting/scouter-leaderboard.html", - scouters=[], - current_sort='match_count', - events=[], - selected_event='all', - teams=[], - selected_team='all' - ) - -@scouting_bp.route("/scouting/matches") -# @limiter.limit("30 per minute") -@login_required -def matches(): - try: - pipeline = [ - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - # Add team access filter - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }}, - {"$group": { - "_id": { - "event": "$event_code", - "match": "$match_number" - }, - "teams": { - "$push": { - "id": "$_id", - "number": "$team_number", - "alliance": "$alliance", - - # Fuel stats - "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, - "auto_climb": {"$ifNull": ["$auto_climb", False]}, - "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, - "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, - "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, - "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, - "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, - "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, - "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, - - "climb_level": {"$ifNull": ["$climb_level", 0]}, - "climb_type": "$climb_type", - "climb_success": "$climb_success" - } - }, - }} - ] - - match_data = list(scouting_manager.db.team_data.aggregate(pipeline)) - service = current_app.extensions.get("match13_fuel") - statuses = service.statuses(t["id"] for match in match_data for t in match["teams"]) if service else {} - matches = [] - - for match in match_data: - red_teams = [t for t in match["teams"] if t["alliance"] == "red"] - blue_teams = [t for t in match["teams"] if t["alliance"] == "blue"] - - # Calculate alliance totals for Fuel - red_fuel = { - "auto": sum(t["auto_fuel"] for t in red_teams), - "transition": sum(t["transition_fuel"] for t in red_teams), - "teleop": sum(t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + - t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] for t in red_teams), - "endgame": sum(t["endgame_fuel"] for t in red_teams), - "total": sum(t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + - t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"] for t in red_teams) - } - - blue_fuel = { - "auto": sum(t["auto_fuel"] for t in blue_teams), - "transition": sum(t["transition_fuel"] for t in blue_teams), - "teleop": sum(t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + - t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] for t in blue_teams), - "endgame": sum(t["endgame_fuel"] for t in blue_teams), - "total": sum(t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + - t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"] for t in blue_teams) - } - - # Prepare team data for template - red_team_data = [{ - "number": t["number"], - "fuel_estimate": statuses.get(str(t["id"])), - "ferried_fuel": t["ferried_fuel"], - "fuel_total": t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"], - "climb_level": t["climb_level"], - "climb_success": t["climb_success"], - "auto_climb": t["auto_climb"] - } for t in red_teams] - - blue_team_data = [{ - "number": t["number"], - "fuel_estimate": statuses.get(str(t["id"])), - "ferried_fuel": t["ferried_fuel"], - "fuel_total": t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"], - "climb_level": t["climb_level"], - "climb_success": t["climb_success"], - "auto_climb": t["auto_climb"] - } for t in blue_teams] - - matches.append({ - "event_code": match["_id"]["event"], - "match_number": match["_id"]["match"], - "red_teams": red_team_data, - "blue_teams": blue_team_data, - "red_fuel": red_fuel, - "blue_fuel": blue_fuel - }) - current_app.logger.info(f"Successfully fetched matches {matches} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("scouting/matches.html", matches=matches) - - except Exception as e: - current_app.logger.error(f"Error fetching matches: {str(e)}", exc_info=True) - flash("An internal error has occurred.", "error") - return render_template("scouting/matches.html", matches=[]) - -# @scouting_bp.route("/scouting/check_team") -# @login_required -# def check_team(): -# team_number = request.args.get('team') -# event_code = request.args.get('event') -# match_number = request.args.get('match') -# current_id = request.args.get('current_id') - -# try: -# # Get current user's team number -# current_user_team = current_user.teamNumber - -# pipeline = [ -# { -# "$match": { -# "team_number": int(team_number), -# "event_code": event_code, -# "match_number": match_number # Keep as string, don't convert to int -# } -# }, -# { -# "$lookup": { -# "from": "users", -# "localField": "scouter_id", -# "foreignField": "_id", -# "as": "scouter" -# } -# }, -# {"$unwind": "$scouter"}, -# ] - -# # Only add current_id filter if it's a valid ObjectId -# if current_id and current_id != 'undefined' and ObjectId.is_valid(current_id): -# pipeline[0]["$match"]["_id"] = {"$ne": ObjectId(current_id)} - -# existing = list(scouting_manager.db.team_data.aggregate(pipeline)) - -# # Check if any existing entry is from the same team -# exists = any(entry.get("scouter", {}).get("teamNumber") == current_user_team -# for entry in existing) - -# return jsonify({"exists": exists}) -# except Exception as e: -# current_app.logger.error(f"Error checking team data: {str(e)}", exc_info=True) -# return jsonify({"error": "An internal error has occurred."}), 500 - -@scouting_bp.route("/scouting/pit") -@login_required -# @limiter.limit("50 per minute") -def pit_scouting(): - try: - # Update to use filtered pit scouting data - pit_data_list = list(scouting_manager.get_all_pit_scouting( - current_user.teamNumber, - current_user.get_id() - )) - current_app.logger.info(f"Successfully fetched pit scouting data {pit_data_list} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("scouting/pit-scouting.html", pit_data=pit_data_list) - except Exception as e: - current_app.logger.error(f"Error fetching pit scouting data: {str(e)}", exc_info=True) - flash("An internal error has occurred.", "error") - return render_template("scouting/pit-scouting.html", pit_data=[]) - -@scouting_bp.route("/scouting/pit/add", methods=["GET", "POST"]) -@login_required -# @limiter.limit("15 per minute") -def pit_scouting_add(): - if request.method == "POST": - try: - current_app.logger.info(f"Scouting.add Form Details {request.form}") - # Process form data - pit_data = { - "team_number": int(request.form.get("team_number")), - "scouter_id": current_user.id, - - # Drive base information - "drive_type": { - "swerve": "swerve" in request.form.getlist("drive_type"), - "tank": "tank" in request.form.getlist("drive_type"), - "other": request.form.get("drive_type_other", "") - }, - "swerve_modules": request.form.get("swerve_modules", ""), - - # Mechanisms - "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), - "shooter_type": request.form.get("shooter_type", ""), - "shooter_notes": request.form.get("shooter_notes", ""), - "hopper_notes": request.form.get("hopper_notes", ""), - "intake_notes": request.form.get("intake_notes", ""), - - # Autonomous - "autonomous_capabilities": { - "has_auto": request.form.get("has_auto") == "true", - "num_routes": int(request.form.get("auto_routes", 0) or 0) if request.form.get("has_auto") == "true" else 0, - "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", - "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" - }, - - # Driver Experience - "driver_experience": { - "years": int(request.form.get("driver_years", 0) if not (request.form.get("driver_years") == '') else 0), - "notes": request.form.get("driver_notes", "") - }, - - # General Notes - "notes": request.form.get("notes", ""), - - # Timestamps - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc) - } - - # Add to database - if scouting_manager.add_pit_scouting(pit_data): - current_app.logger.info(f"Successfully added pit scouting data {pit_data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Pit scouting data added successfully!", "success") - return redirect(url_for("scouting.pit_scouting")) - else: - current_app.logger.info(f"Failed to add pit scouting data {pit_data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Error adding pit scouting data. Please try again.", "error") - except Exception as e: - flash("An error occurred while adding pit scouting data.", "error") - current_app.logger.error(f"Error adding pit scouting data: {str(e)}", exc_info=True) - return redirect(url_for("scouting.pit_scouting")) - - return render_template("scouting/pit-scouting-add.html") - -@scouting_bp.route("/scouting/pit/edit/", methods=["GET", "POST"]) -@login_required -# @limiter.limit("10 per minute") -def pit_scouting_edit(entry_id): - pit_data = scouting_manager.get_pit_scouting_by_id(entry_id) - if not pit_data: - flash("Pit scouting data not found", "error") - return redirect(url_for("scouting.pit_scouting")) - - if str(pit_data["scouter_id"]) != current_user.get_id(): - flash("You don't have permission to edit this data", "error") - return redirect(url_for("scouting.pit_scouting")) - - if request.method == "POST": - try: - current_app.logger.info(f"Scouting.edit Form Details {request.form}") - data = { - "team_number": int(request.form["team_number"]), - "scouter_id": ObjectId(current_user.get_id()), - "drive_type": { - "swerve": "swerve" in request.form.getlist("drive_type"), - "tank": "tank" in request.form.getlist("drive_type"), - "other": request.form.get("drive_type_other", "") - }, - "swerve_modules": request.form.get("swerve_modules", ""), - - # Mechanisms - "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), - "shooter_type": request.form.get("shooter_type", ""), - "shooter_notes": request.form.get("shooter_notes", ""), - "hopper_notes": request.form.get("hopper_notes", ""), - "intake_notes": request.form.get("intake_notes", ""), - - "autonomous_capabilities": { - "has_auto": request.form.get("has_auto") == "true", - "num_routes": int(request.form.get("auto_routes", 0)) if request.form.get("has_auto") == "true" else 0, - "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", - "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" - }, - "driver_experience": { - "years": int(request.form.get("driver_years", 0)), - "notes": request.form.get("driver_notes", "") - }, - "notes": request.form.get("notes", ""), - "updated_at": datetime.now(timezone.utc) - } - - if scouting_manager.update_pit_scouting(entry_id, data, current_user.get_id()): - current_app.logger.info(f"Successfully updated pit scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Pit scouting data updated successfully", "success") - return redirect(url_for("scouting.pit_scouting")) - else: - current_app.logger.info(f"Failed to update pit scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Error updating pit scouting data", "error") - except Exception as e: - current_app.logger.info(f"Error updating pit scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'} {str(e)}", exc_info=True) - flash("An internal error has occurred.", "error") - - return render_template("scouting/pit-scouting-edit.html", entry=pit_data) - -@scouting_bp.route("/scouting/pit/delete/") -@login_required -def pit_scouting_delete(entry_id): - if scouting_manager.delete_pit_scouting(entry_id, current_user.get_id()): - current_app.logger.info(f"Successfully deleted pit scouting entry {entry_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Pit scouting data deleted successfully", "success") - else: - current_app.logger.info(f"Failed to delete pit scouting entry {entry_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - flash("Error deleting pit scouting data", "error") - return redirect(url_for("scouting.pit_scouting")) - -@scouting_bp.route("/api/tba/events") -@login_required -# @limiter.limit("30 per minute") -def get_tba_events(): - try: - year = request.args.get('year', default=datetime.now().year, type=int) - tba = TBAInterface() - background = request.args.get('background') == '1' - events = tba.get_current_events(year, include_test_data=background, - background=background) - if background: - response = jsonify(events or {}) - response.headers['X-Scouting-Pending'] = str( - tba.pending(f'/events/{year}/simple')).lower() - return response - event_count = len(events) if events else 0 - current_app.logger.info(f"Successfully fetched {event_count} TBA events for year {year} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return jsonify(events) - except Exception as e: - current_app.logger.error(f"Error getting TBA events: {e}") - return jsonify({"error": "Failed to fetch events"}), 500 - -@scouting_bp.route("/api/tba/matches/") -@login_required -# @limiter.limit("30 per minute") -def get_tba_matches(event_key): - try: - tba = TBAInterface() - matches = tba.get_event_matches(event_key, background=True) - response = jsonify(matches) - response.headers['X-Scouting-Pending'] = str( - tba.pending(f'/event/{event_key}/matches')).lower() - return response - except Exception as e: - current_app.logger.error(f"Error getting TBA matches: {e}") - return jsonify({"error": "Failed to fetch matches"}), 500 - -@scouting_bp.route("/scouting/live-match-status", methods=["GET"]) -@login_required -# @limiter.limit("30 per minute") -def live_match_status(): - """Route for the live team schedule modal""" - team_number = request.args.get('team') - event_code = request.args.get('event') - - # Default to empty context data - context = { - 'team_number': team_number, - 'event_code': event_code - } - current_app.logger.info(f"Successfully fetched live match status {context} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("scouting/live-match-status.html", **context) - -@scouting_bp.route("/api/tba/team-status") -@login_required -def get_team_status(): - """Get team status at an event including ranking and matches""" - team_number = request.args.get('team') - event_code = request.args.get('event') - - if not team_number: - return jsonify({"error": "Team number is required"}), 400 - - try: - # Format TBA team key - team_key = f"frc{team_number}" - - # Initialize TBA interface - tba = TBAInterface() - - # If event code not provided, find the most recent event - if not event_code: - most_recent_event = tba.get_most_recent_active_event(team_key) - if not most_recent_event: - return jsonify({"error": "No events found for this team"}), 404 - - event_code = most_recent_event.get('key') - # Also return event details for the UI - event_name = most_recent_event.get('name', 'Unknown Event') - # Get team status at event (ranking) - status = tba.get_team_status_at_event(team_key, event_code) - - # Get team matches at event - matches = tba.get_team_matches_at_event(team_key, event_code) - current_app.logger.info(f"Successfully fetched team status {status} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - - return jsonify({ - "status": status, - "matches": matches, - "event": { - "key": event_code, - "name": event_name if 'event_name' in locals() else None - } - }) - except Exception as e: - logger.error(f"Error getting team status: {e}") - return jsonify({"error": "Failed to fetch team status"}), 500 - -@scouting_bp.route("/api/team_paths") -@login_required -# @limiter.limit("30 per minute") -def get_team_paths(): - team_number = request.args.get('team') - - if not team_number: - return jsonify({"error": "Team number is required"}), 400 - - try: - team_number = int(team_number) - - # Build pipeline to get paths for the team - pipeline = [ - {"$match": {"team_number": team_number}}, - {"$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - }}, - {"$unwind": {"path": "$scouter", "preserveNullAndEmptyArrays": True}}, - # Add team access filter - {"$match": { - "$or": [ - {"scouter.teamNumber": current_user.teamNumber} if current_user.teamNumber else {"scouter._id": ObjectId(current_user.get_id())}, - {"scouter._id": ObjectId(current_user.get_id())} - ] - }}, - # Only get matches with auto path data - {"$match": {"auto_path": {"$exists": True, "$ne": []}}}, - # Sort by most recent matches first - {"$sort": {"match_number": -1}}, - # Project only the needed fields - {"$project": { - "_id": {"$toString": "$_id"}, - "team_number": 1, - "match_number": 1, - "event_code": 1, - "event_name": 1, - "alliance": 1, - "auto_path": 1, - "auto_notes": 1, - "scouter_name": "$scouter.username", - "scouter_id": {"$toString": "$scouter._id"} - }} - ] - - # Get team info from TBA - tba = TBAInterface() - team_key = f"frc{team_number}" - team_info = tba.get_team(team_key) or {} - - # Get paths from database - paths = list(scouting_manager.db.team_data.aggregate(pipeline)) - - # Format response - response = { - "team_number": team_number, - "team_info": { - "nickname": team_info.get("nickname", "Unknown"), - "city": team_info.get("city", ""), - "state_prov": team_info.get("state_prov", ""), - "country": team_info.get("country", "") - }, - "paths": paths - } - - current_app.logger.info(f"Successfully fetched team paths {response} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return json_util.dumps(response), 200, {'Content-Type': 'application/json'} - - except Exception as e: - current_app.logger.error(f"Error fetching team paths: {str(e)}", exc_info=True) - return jsonify({"error": "Failed to fetch team path data."}), 500 - - -@scouting_bp.route("/scouting/mock-alliance-selection") -@login_required -@limiter.limit("30 per minute") -def mock_alliance_selection(): - """ - Render the mock alliance selection page. - - Returns: - Rendered HTML template for the mock alliance selection page. - """ - current_app.logger.info(f"Successfully loaded mock alliance selection for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") - return render_template("scouting/mock-alliance-selection.html") - - -@scouting_bp.route("/api/alliance-selection/rankings/") -@login_required -def get_alliance_rankings(event_key): - """Get team rankings for alliance selection""" - # basic validation for event_key (year + event code) - if not event_key or not event_key.isalnum(): - return jsonify({"error": "Invalid event key format"}), 400 - - try: - tba = TBAInterface() - rankings = tba.get_event_rankings(event_key) - - if not rankings: - return jsonify({"error": "Failed to fetch rankings"}), 404 - - # Fetch all team details in bulk for the event - teams = tba.get_event_teams(event_key) - team_info_map = {team['key']: team for team in teams} if teams else {} - - for rank in rankings: - team_info = team_info_map.get(rank['team_key']) - if team_info: - rank['nickname'] = team_info.get('nickname', '') - rank['city'] = team_info.get('city', '') - rank['state_prov'] = team_info.get('state_prov', '') - - current_app.logger.info(f"Successfully fetched alliance rankings for event {event_key}") - return jsonify(rankings) - - except Exception as e: - current_app.logger.error(f"Error fetching alliance rankings: {str(e)}", exc_info=True) - return jsonify({"error": "Failed to fetch rankings"}), 500 - -@scouting_bp.route("/api/team_epa_opr", methods=["POST"]) -@login_required -def get_team_epa_opr(): - """Public metrics must never occupy web workers waiting on upstream APIs.""" - from app.scout.team_stats import team_stats_snapshot - - data = request.get_json(silent=True) - if not isinstance(data, dict): - return jsonify({"error": "Expected a JSON object"}), 400 - teams = data.get("teams", []) - try: - year = int(data.get("year", datetime.now().year)) - if not 1992 <= year <= datetime.now().year + 1: - raise ValueError() - if not isinstance(teams, list) or len(teams) > 2000: - raise ValueError() - normalized = [] - for item in teams: - if not isinstance(item, dict): - raise ValueError() - number = int(item.get("team_number", 0)) - event = item.get("event_code") - if not 1 <= number <= 999999 or (event is not None and - (not isinstance(event, str) or len(event) > 200)): - raise ValueError() - normalized.append({"team_number": number, "event_code": event}) - except (ValueError, TypeError, OverflowError): - return jsonify({"error": "Invalid teams, event codes, or year"}), 400 - return jsonify(team_stats_snapshot(normalized, year)) diff --git a/app/scout/routes/__init__.py b/app/scout/routes/__init__.py new file mode 100644 index 0000000..5a4ed5b --- /dev/null +++ b/app/scout/routes/__init__.py @@ -0,0 +1,5 @@ +"""Scouting blueprint; importing the view modules registers their routes.""" +from . import events, leaderboards, lighthouse, pit, records +from .common import scouting_bp + +__all__ = ["scouting_bp", "events", "leaderboards", "lighthouse", "pit", "records"] diff --git a/app/scout/routes/common.py b/app/scout/routes/common.py new file mode 100644 index 0000000..bb2f509 --- /dev/null +++ b/app/scout/routes/common.py @@ -0,0 +1,69 @@ +"""Blueprint, shared manager, and helpers for every scouting view module.""" +from __future__ import annotations + +import logging +import os + +from bson import ObjectId +from flask import Blueprint + +from app.scout.scouting_utils import ScoutingManager + +from ..match13_fuel import PHASES, FuelEstimateService + +scouting_bp = Blueprint("scouting", __name__) +scouting_manager: ScoutingManager | None = None + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +TELEOP_SHIFT_FIELDS = ("teleop_shift_1_fuel", "teleop_shift_2_fuel", + "teleop_shift_3_fuel", "teleop_shift_4_fuel") + +SCOUTER_LOOKUP = {"$lookup": { + "from": "users", + "localField": "scouter_id", + "foreignField": "_id", + "as": "scouter" +}} + + +@scouting_bp.record +def on_blueprint_init(state): + global scouting_manager + app = state.app + + # Create ScoutingManager with the singleton connection + scouting_manager = ScoutingManager(app.config["MONGO_URI"]) + + # Store in app context for proper cleanup + if not hasattr(app, 'db_managers'): + app.db_managers = {} + app.db_managers['scouting'] = scouting_manager + try: + service = FuelEstimateService( + scouting_manager.db, os.getenv("MATCH13_STATE_PATH") or os.path.join(app.instance_path, "match13.sqlite"), + api_key=os.getenv("MATCH_13_API_KEY") or os.getenv("MATCH13_API_KEY", ""), + tba_key=os.getenv("TBA_AUTH_KEY", "")) + app.extensions["match13_fuel"] = service + service.start() + except Exception: + app.logger.exception("Fuel estimates unavailable; normal scouting is unaffected") + + +def actor_name(user) -> str: + """Username for log lines.""" + return user.username if user.is_authenticated else 'Anonymous' + + +def team_access_match(user) -> dict: + """$match stage (after SCOUTER_LOOKUP + $unwind) limiting records to the user's own or their team's.""" + access = {"scouter._id": ObjectId(user.get_id())} + if user.teamNumber: + access = {"$or": [access, {"scouter.teamNumber": user.teamNumber}]} + return {"$match": access} + + +def fuel_total(record: dict): + """Scored fuel across every phase (excludes ferried fuel).""" + return sum(record[field] for field in PHASES) diff --git a/app/scout/routes/events.py b/app/scout/routes/events.py new file mode 100644 index 0000000..00e976a --- /dev/null +++ b/app/scout/routes/events.py @@ -0,0 +1,182 @@ +"""Event data from TBA/Statbotics: events, matches, live status, alliance selection, EPA/OPR.""" +from __future__ import annotations + +from datetime import datetime + +from flask import current_app, jsonify, render_template, request +from flask_login import current_user, login_required + +from app.utils import limiter + +from ..TBA import TBAInterface +from .common import actor_name, logger, scouting_bp + + +@scouting_bp.route("/api/tba/events") +@login_required +# @limiter.limit("30 per minute") +def get_tba_events(): + try: + year = request.args.get('year', default=datetime.now().year, type=int) + tba = TBAInterface() + background = request.args.get('background') == '1' + events = tba.get_current_events(year, include_test_data=background, + background=background) + if background: + response = jsonify(events or {}) + response.headers['X-Scouting-Pending'] = str( + tba.pending(f'/events/{year}/simple')).lower() + return response + event_count = len(events) if events else 0 + current_app.logger.info(f"Successfully fetched {event_count} TBA events for year {year} for user {actor_name(current_user)}") + return jsonify(events) + except Exception as e: + current_app.logger.error(f"Error getting TBA events: {e}") + return jsonify({"error": "Failed to fetch events"}), 500 + + +@scouting_bp.route("/api/tba/matches/") +@login_required +# @limiter.limit("30 per minute") +def get_tba_matches(event_key): + try: + tba = TBAInterface() + matches = tba.get_event_matches(event_key, background=True) + response = jsonify(matches) + response.headers['X-Scouting-Pending'] = str( + tba.pending(f'/event/{event_key}/matches')).lower() + return response + except Exception as e: + current_app.logger.error(f"Error getting TBA matches: {e}") + return jsonify({"error": "Failed to fetch matches"}), 500 + + +@scouting_bp.route("/scouting/live-match-status", methods=["GET"]) +@login_required +# @limiter.limit("30 per minute") +def live_match_status(): + """Route for the live team schedule modal""" + context = { + 'team_number': request.args.get('team'), + 'event_code': request.args.get('event') + } + current_app.logger.info(f"Successfully fetched live match status {context} for user {actor_name(current_user)}") + return render_template("scouting/live-match-status.html", **context) + + +@scouting_bp.route("/api/tba/team-status") +@login_required +def get_team_status(): + """Get team status at an event including ranking and matches""" + team_number = request.args.get('team') + event_code = request.args.get('event') + + if not team_number: + return jsonify({"error": "Team number is required"}), 400 + + try: + team_key = f"frc{team_number}" + tba = TBAInterface() + event_name = None + + # If event code not provided, find the most recent event + if not event_code: + most_recent_event = tba.get_most_recent_active_event(team_key) + if not most_recent_event: + return jsonify({"error": "No events found for this team"}), 404 + + event_code = most_recent_event.get('key') + # Also return event details for the UI + event_name = most_recent_event.get('name', 'Unknown Event') + # Get team status at event (ranking) + status = tba.get_team_status_at_event(team_key, event_code) + + # Get team matches at event + matches = tba.get_team_matches_at_event(team_key, event_code) + current_app.logger.info(f"Successfully fetched team status {status} for user {actor_name(current_user)}") + + return jsonify({ + "status": status, + "matches": matches, + "event": { + "key": event_code, + "name": event_name + } + }) + except Exception as e: + logger.error(f"Error getting team status: {e}") + return jsonify({"error": "Failed to fetch team status"}), 500 + + +@scouting_bp.route("/scouting/mock-alliance-selection") +@login_required +@limiter.limit("30 per minute") +def mock_alliance_selection(): + """Render the mock alliance selection page.""" + current_app.logger.info(f"Successfully loaded mock alliance selection for user {actor_name(current_user)}") + return render_template("scouting/mock-alliance-selection.html") + + +@scouting_bp.route("/api/alliance-selection/rankings/") +@login_required +def get_alliance_rankings(event_key): + """Get team rankings for alliance selection""" + # basic validation for event_key (year + event code) + if not event_key or not event_key.isalnum(): + return jsonify({"error": "Invalid event key format"}), 400 + + try: + tba = TBAInterface() + rankings = tba.get_event_rankings(event_key) + + if not rankings: + return jsonify({"error": "Failed to fetch rankings"}), 404 + + # Fetch all team details in bulk for the event + teams = tba.get_event_teams(event_key) + team_info_map = {team['key']: team for team in teams} if teams else {} + + for rank in rankings: + team_info = team_info_map.get(rank['team_key']) + if team_info: + rank['nickname'] = team_info.get('nickname', '') + rank['city'] = team_info.get('city', '') + rank['state_prov'] = team_info.get('state_prov', '') + + current_app.logger.info(f"Successfully fetched alliance rankings for event {event_key}") + return jsonify(rankings) + + except Exception as e: + current_app.logger.error(f"Error fetching alliance rankings: {str(e)}", exc_info=True) + return jsonify({"error": "Failed to fetch rankings"}), 500 + + +@scouting_bp.route("/api/team_epa_opr", methods=["POST"]) +@login_required +def get_team_epa_opr(): + """Public metrics must never occupy web workers waiting on upstream APIs.""" + from app.scout.team_stats import team_stats_snapshot + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Expected a JSON object"}), 400 + teams = data.get("teams", []) + try: + year = int(data.get("year", datetime.now().year)) + if not 1992 <= year <= datetime.now().year + 1: + raise ValueError() + if not isinstance(teams, list) or len(teams) > 2000: + raise ValueError() + normalized = [] + for item in teams: + if not isinstance(item, dict): + raise ValueError() + number = int(item.get("team_number", 0)) + event = item.get("event_code") + if not 1 <= number <= 999999 or (event is not None and + (not isinstance(event, str) or len(event) > 200)): + raise ValueError() + normalized.append({"team_number": number, "event_code": event}) + except (ValueError, TypeError, OverflowError): + return jsonify({"error": "Invalid teams, event codes, or year"}), 400 + return jsonify(team_stats_snapshot(normalized, year)) diff --git a/app/scout/routes/leaderboards.py b/app/scout/routes/leaderboards.py new file mode 100644 index 0000000..76933d0 --- /dev/null +++ b/app/scout/routes/leaderboards.py @@ -0,0 +1,317 @@ +"""Team and scouter leaderboards.""" +from __future__ import annotations + +from datetime import datetime + +from flask import current_app, render_template, request +from flask_login import current_user, login_required + +from . import common +from .common import SCOUTER_LOOKUP, actor_name, scouting_bp, team_access_match + + +@scouting_bp.route("/leaderboard") +# @limiter.limit("30 per minute") +def leaderboard(): + try: + MIN_MATCHES = 1 + sort_type = request.args.get('sort', 'fuel') + selected_event = request.args.get('event', 'all') + + # Get available events from scouting data + # Filter by team access: only show events from user's team or user himself + events_pipeline = [ + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + team_access_match(current_user), + # Group by event code to get unique events + {"$group": { + "_id": "$event_code", + "event_name": {"$first": "$event_name"}, + "count": {"$sum": 1} + }}, + {"$sort": {"_id": 1}} + ] + + events = list(common.scouting_manager.db.team_data.aggregate(events_pipeline)) + + # Main pipeline for team data + pipeline = [ + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + team_access_match(current_user) + ] + + # Filter by selected event if not 'all' + if selected_event != 'all': + pipeline.append({"$match": {"event_code": selected_event}}) + + # Continue with the existing aggregation + pipeline.extend([ + {"$group": { + "_id": "$team_number", + "matches_played": {"$sum": 1}, + + # Auto Fuel + "auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, + "transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, + + # Teleop Fuel + "teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, + "teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, + "teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, + "teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, + + # Endgame Fuel + "endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, + + # Climb Level + "auto_climb_sum": {"$sum": {"$cond": [{"$eq": ["$auto_climb", True]}, 1, 0]}}, + "climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, + + # Defense Rating + "defense_rating": {"$avg": {"$ifNull": ["$defense_rating", 0]}}, + # Robot Disabled + "robot_disabled_list": {"$push": "$robot_disabled"}, + + "herd_rating": { + "$avg": { + "$cond": [{"$eq": ["$herd_balls", True]}, {"$ifNull": ["$herd_rating", 0]}, None] + } + }, + "herd_count": {"$sum": {"$cond": [{"$eq": ["$herd_balls", True]}, 1, 0]}}, + + "ferrying_rating": { + "$avg": { + "$cond": [{"$eq": ["$ferrying_did", True]}, {"$ifNull": ["$ferrying_rating", 0]}, None] + } + }, + "ferrying_count": {"$sum": {"$cond": [{"$eq": ["$ferrying_did", True]}, 1, 0]}}, + + # Climb stats + "climb_level_1_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 1]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_level_2_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 2]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_level_3_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 3]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_attempts": {"$sum": 1}, + "climb_successes": { + "$sum": {"$cond": [{"$eq": ["$climb_success", True]}, 1, 0]} + }, + }}, + {"$match": {"matches_played": {"$gte": MIN_MATCHES}}}, + {"$project": { + "team_number": "$_id", + "matches_played": 1, + + "auto_fuel": {"$round": ["$auto_fuel", 1]}, + "transition_fuel": {"$round": ["$transition_fuel", 1]}, + "ferried_fuel": {"$round": ["$ferried_fuel", 1]}, + "teleop_fuel_total": { + "$add": [ + "$transition_fuel", + "$teleop_shift_1_fuel", + "$teleop_shift_2_fuel", + "$teleop_shift_3_fuel", + "$teleop_shift_4_fuel" + ] + }, + "endgame_fuel": {"$round": ["$endgame_fuel", 1]}, + + # Calculate totals for each category + "total_fuel": { + "$add": [ + "$auto_fuel", + "$transition_fuel", + "$teleop_shift_1_fuel", + "$teleop_shift_2_fuel", + "$teleop_shift_3_fuel", + "$teleop_shift_4_fuel", + "$endgame_fuel" + ] + }, + + "climb_level_avg": {"$round": ["$climb_level", 1]}, + + "auto_climb_pct": { + "$multiply": [ + {"$divide": ["$auto_climb_sum", "$matches_played"]}, + 100 + ] + }, + "climb_l1_pct": { + "$multiply": [ + {"$divide": ["$climb_level_1_success", "$matches_played"]}, + 100 + ] + }, + "climb_l2_pct": { + "$multiply": [ + {"$divide": ["$climb_level_2_success", "$matches_played"]}, + 100 + ] + }, + "climb_l3_pct": { + "$multiply": [ + {"$divide": ["$climb_level_3_success", "$matches_played"]}, + 100 + ] + }, + "climb_success_rate": { + "$multiply": [ + {"$cond": [ + {"$gt": ["$climb_attempts", 0]}, + {"$divide": ["$climb_successes", "$climb_attempts"]}, + 0 + ]}, + 100 + ] + }, + "defense_rating": {"$round": ["$defense_rating", 1]}, + "robot_disabled_list": "$robot_disabled_list", + "herd_rating": {"$round": [{"$ifNull": ["$herd_rating", 0]}, 1]}, + "herd_count": 1, + "ferrying_rating": {"$round": [{"$ifNull": ["$ferrying_rating", 0]}, 1]}, + "ferrying_count": 1 + }} + ]) + + # We cannot sort purely by TBA-fetched metrics natively in MongoDB aggregation + # (EPA/OPR/DPR/CCWM require secondary API calls). Let's process those *after* + # the aggregate completes if one of those sorts is requested. + + tba_metrics = {'epa', 'opr', 'dpr', 'ccwm'} + + if sort_type not in tba_metrics: + sort_field = { + 'fuel': 'total_fuel', + 'auto_fuel': 'auto_fuel', + 'ferried_fuel': 'ferried_fuel', + 'auto_climb': 'auto_climb_pct', + 'climb': 'climb_success_rate', + 'climb_l1': 'climb_l1_pct', + 'climb_l2': 'climb_l2_pct', + 'climb_l3': 'climb_l3_pct', + 'defense': 'defense_rating', + 'herd_rating': 'herd_rating', + 'ferrying_rating': 'ferrying_rating' + }.get(sort_type, 'total_fuel') + + pipeline.append({"$sort": {sort_field: -1}}) + + teams = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + current_app.logger.info(f"Successfully fetched leaderboard {teams} for user {actor_name(current_user)}") + + if sort_type in tba_metrics: + from app.scout.team_stats import team_stats_snapshot + snapshots = team_stats_snapshot([ + {"team_number": team["team_number"], "event_code": selected_event} + for team in teams + ], datetime.now().year) + for team in teams: + snapshot = snapshots.get(f"{team['team_number']}_{selected_event}", {}) + team[sort_type] = snapshot.get(sort_type) + teams.sort(key=lambda team: (team.get(sort_type) is None, + (team.get(sort_type) or 0) * + (1 if sort_type == 'dpr' else -1))) + + return render_template("scouting/leaderboard.html", teams=teams, current_sort=sort_type, + events=events, selected_event=selected_event) + except Exception as e: + current_app.logger.error(f"Error in leaderboard: {str(e)}", exc_info=True) + return render_template("scouting/leaderboard.html", teams=[], current_sort='fuel', + events=[], selected_event='all') + + +@scouting_bp.route("/scouter-leaderboard") +# @limiter.limit("30 per minute") +@login_required +def scouter_leaderboard(): + try: + sort_by = request.args.get('sort', 'match_count') + selected_event = request.args.get('event', 'all') + selected_team = request.args.get('team', 'all') + + # Get list of events for filtering + events_pipeline = [ + {"$group": {"_id": "$event_code"}}, + {"$sort": {"_id": 1}} + ] + events = [evt["_id"] for evt in common.scouting_manager.db.team_data.aggregate(events_pipeline)] + + # Build pipeline to count scouting entries by user + pipeline = [ + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + ] + + # Apply event filter if specified + if selected_event != 'all': + pipeline.append({"$match": {"event_code": selected_event}}) + + # Apply team filter if specified + if selected_team != 'all' and selected_team.isdigit(): + pipeline.append({"$match": {"scouter.teamNumber": int(selected_team)}}) + + # Group by scouter and count + pipeline.extend([ + { + "$group": { + "_id": "$scouter._id", + "username": {"$first": "$scouter.username"}, + "teamNumber": {"$first": "$scouter.teamNumber"}, + "match_count": {"$sum": 1}, + "unique_teams": {"$addToSet": "$team_number"}, + } + }, + { + "$project": { + "username": 1, + "teamNumber": 1, + "match_count": 1, + "unique_teams_count": {"$size": "$unique_teams"}, + } + } + ]) + + # Sort by selected field + sort_field = { + 'match_count': 'match_count', + 'unique_teams': 'unique_teams_count', + }.get(sort_by, 'match_count') + + pipeline.append({"$sort": {sort_field: -1}}) + + # Execute query + scouters = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + + # Get list of all teams for filtering + teams_pipeline = [ + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + {"$match": {"scouter.teamNumber": {"$exists": True, "$ne": None}}}, + {"$group": {"_id": "$scouter.teamNumber"}}, + {"$sort": {"_id": 1}} + ] + teams = [team["_id"] for team in common.scouting_manager.db.team_data.aggregate(teams_pipeline)] + + current_app.logger.info(f"Successfully fetched scouter leaderboard {scouters} for user {actor_name(current_user)}") + return render_template( + "scouting/scouter-leaderboard.html", + scouters=scouters, + current_sort=sort_by, + events=events, + selected_event=selected_event, + teams=teams, + selected_team=selected_team + ) + except Exception as e: + current_app.logger.error(f"Error fetching scouter leaderboard: {str(e)}", exc_info=True) + return render_template( + "scouting/scouter-leaderboard.html", + scouters=[], + current_sort='match_count', + events=[], + selected_event='all', + teams=[], + selected_team='all' + ) diff --git a/app/scout/routes/lighthouse.py b/app/scout/routes/lighthouse.py new file mode 100644 index 0000000..449dff4 --- /dev/null +++ b/app/scout/routes/lighthouse.py @@ -0,0 +1,303 @@ +"""Lighthouse pages and their team compare / search / auto path APIs.""" +from __future__ import annotations + +import aiohttp +from bson import json_util +from flask import current_app, jsonify, render_template, request +from flask_login import current_user, login_required + +from app.utils import async_route + +from ..TBA import TBAInterface +from . import common +from .common import SCOUTER_LOOKUP, actor_name, scouting_bp, team_access_match + + +@scouting_bp.route("/lighthouse") +# @limiter.limit("30 per minute") +@login_required +def lighthouse(): + current_app.logger.info(f"Successfully fetched lighthouse for user {actor_name(current_user)}") + return render_template("lighthouse.html") + + +@scouting_bp.route("/lighthouse/auton") +# @limiter.limit("30 per minute") +@login_required +def auton(): + current_app.logger.info(f"Successfully fetched lighthouse/auton for user {actor_name(current_user)}") + return render_template("lighthouse/auton.html") + + +@scouting_bp.route("/api/compare") +# @limiter.limit("30 per minute") +@login_required +def compare_teams(): + try: + teams = [] + for i in range(1, 4): + if team_num := request.args.get(f'team{i}'): + teams.append(int(team_num)) + + if len(teams) < 2: + return jsonify({"error": "At least 2 teams are required"}), 400 + + teams_data = {} + for team_num in teams: + try: + pipeline = [ + {"$match": {"team_number": team_num}}, + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + team_access_match(current_user), + {"$group": { + "_id": "$team_number", + "matches_played": {"$sum": 1}, + + # Fuel Stats + "avg_auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, + "avg_auto_climb": {"$avg": {"$cond": ["$auto_climb", 1, 0]}}, + "avg_transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "avg_ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, + "avg_teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, + "avg_teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, + "avg_teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, + "avg_teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, + "avg_endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, + + # Climb Stats + "climb_success_rate": {"$avg": {"$cond": ["$climb_success", 1, 0]}}, + "climb_level_1_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 1]}, 1, 0]}}, + "climb_level_2_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 2]}, 1, 0]}}, + "climb_level_3_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 3]}, 1, 0]}}, + "avg_climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, + + "defense_notes": {"$push": "$defense_notes"}, + "auto_paths": {"$push": { + "path": "$auto_path", + "notes": "$auto_notes", + "match_number": "$match_number" + }}, + "defense_rating": {"$avg": {"$cond": [{"$gt": ["$defense_rating", 0]}, "$defense_rating", None]}}, + "robot_disabled_list": {"$push": "$robot_disabled"}, + "preferred_climb_type": {"$last": "$climb_type"}, + "matches": {"$push": "$$ROOT"} + }}, + {"$match": {"matches_played": {"$gt": 0}}} + ] + + if stats := list( + common.scouting_manager.db.team_data.aggregate(pipeline) + ): + + # Calculate total teleop average + avg_teleop_total = ( + (stats[0].get("avg_transition_fuel") or 0) + + (stats[0].get("avg_ferried_fuel") or 0) + + (stats[0]["avg_teleop_shift_1_fuel"] or 0) + + (stats[0]["avg_teleop_shift_2_fuel"] or 0) + + (stats[0]["avg_teleop_shift_3_fuel"] or 0) + + (stats[0]["avg_teleop_shift_4_fuel"] or 0) + ) + + normalized_stats = { + # TODO + "auto_scoring": min((stats[0]["avg_auto_fuel"] or 0) / 10, 1), # Assumes ~10 max auto fuel + "teleop_scoring": min(avg_teleop_total / 30, 1), # Assumes ~30 max teleop fuel + "endgame_scoring": min((stats[0]["avg_endgame_fuel"] or 0) / 10, 1), # Assumes ~10 max endgame fuel + "climb_level": min((stats[0]["avg_climb_level"] or 0) / 3, 1), # Max level 3 + "climb_rating": stats[0]["climb_success_rate"], + "defense_rating": (stats[0]["defense_rating"] or 0) / 5 if stats[0].get("defense_rating") is not None else 0 + } + + # Get team info from TBA + team_key = f"frc{team_num}" + team_info = TBAInterface().get_team(team_key) + + teams_data[str(team_num)] = { + "team_number": team_num, + "nickname": team_info.get("nickname", "Unknown"), + "city": team_info.get("city"), + "state_prov": team_info.get("state_prov"), + "country": team_info.get("country"), + "stats": stats[0], + "normalized_stats": normalized_stats, + "matches": stats[0]["matches"] + } + + except Exception as team_error: + current_app.logger.error(f"Error processing team {team_num}: {str(team_error)}", exc_info=True) + + if not teams_data: + return jsonify({"error": "No data available for the selected teams"}), 404 + + current_app.logger.info(f"Successfully fetched team data {teams_data} for user {actor_name(current_user)}") + return json_util.dumps(teams_data) + + except Exception as e: + current_app.logger.error(f"Error in compare_teams: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while comparing teams"}), 500 + + +@scouting_bp.route("/api/search") +@login_required +# @limiter.limit("30 per minute") +@async_route +async def search_teams(): + query = request.args.get("q", "").strip() + if not query: + return jsonify([]) + + try: + # Initialize TBA interface + tba = TBAInterface() + + # Handle both numeric and text searches + if query.isdigit(): + team_key = f"frc{query}" + 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([]) + team = await response.json() + else: + # Search by team name/nickname + url = f"{tba.base_url}/teams/search/{query}" + async with aiohttp.ClientSession(headers=tba.headers) as session: + async with session.get(url) as response: + if response.status != 200: + return jsonify([]) + teams = await response.json() + if not teams: + return jsonify([]) + team = teams[0] # Take the first match + + # Get team number from the response + team_number = team.get("team_number") + + # Fetch scouting data from our database + pipeline = [ + {"$match": {"team_number": team_number}}, + SCOUTER_LOOKUP, + {"$unwind": {"path": "$scouter"}}, + team_access_match(current_user), + {"$sort": {"event_code": 1, "match_number": 1}}, + { + "$project": { + "_id": {"$toString": "$_id"}, # Convert ObjectId to string + "event_code": 1, + "match_number": 1, + # Fuel Stats + "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, + "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, + "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, + "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, + "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, + "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, + "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, + + # Climb Stats + "auto_climb": 1, + "climb_level": {"$ifNull": ["$climb_level", 0]}, + "climb_type": 1, + "climb_success": 1, + + "auto_path": 1, + "auto_notes": 1, + "defense_rating": {"$ifNull": ["$defense_rating", 0]}, + "notes": 1, + "scouter_name": "$scouter.username", + "scouter_id": {"$toString": "$scouter._id"} + } + } + ] + + scouting_data = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + + # Format response + response_data = [{ + "team_number": team_number, + "nickname": team.get("nickname"), + "school_name": team.get("school_name"), + "city": team.get("city"), + "state_prov": team.get("state_prov"), + "country": team.get("country"), + "scouting_data": scouting_data, + "has_team_page": bool(scouting_data) # True if we have any scouting data + }] + + # Use json_util.dumps to handle MongoDB types + current_app.logger.info(f"Successfully fetched team data {response_data} for user {actor_name(current_user)}") + return json_util.dumps(response_data), 200, {'Content-Type': 'application/json'} + + except Exception as e: + current_app.logger.error(f"Error in search_teams: {str(e)}", exc_info=True) + return jsonify({"error": "Failed to fetch team data due to an internal error."}), 500 + + +@scouting_bp.route("/api/team_paths") +@login_required +# @limiter.limit("30 per minute") +def get_team_paths(): + team_number = request.args.get('team') + + if not team_number: + return jsonify({"error": "Team number is required"}), 400 + + try: + team_number = int(team_number) + + # Build pipeline to get paths for the team + pipeline = [ + {"$match": {"team_number": team_number}}, + SCOUTER_LOOKUP, + {"$unwind": {"path": "$scouter", "preserveNullAndEmptyArrays": True}}, + team_access_match(current_user), + # Only get matches with auto path data + {"$match": {"auto_path": {"$exists": True, "$ne": []}}}, + # Sort by most recent matches first + {"$sort": {"match_number": -1}}, + # Project only the needed fields + {"$project": { + "_id": {"$toString": "$_id"}, + "team_number": 1, + "match_number": 1, + "event_code": 1, + "event_name": 1, + "alliance": 1, + "auto_path": 1, + "auto_notes": 1, + "scouter_name": "$scouter.username", + "scouter_id": {"$toString": "$scouter._id"} + }} + ] + + # Get team info from TBA + tba = TBAInterface() + team_key = f"frc{team_number}" + team_info = tba.get_team(team_key) or {} + + # Get paths from database + paths = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + + # Format response + response = { + "team_number": team_number, + "team_info": { + "nickname": team_info.get("nickname", "Unknown"), + "city": team_info.get("city", ""), + "state_prov": team_info.get("state_prov", ""), + "country": team_info.get("country", "") + }, + "paths": paths + } + + current_app.logger.info(f"Successfully fetched team paths {response} for user {actor_name(current_user)}") + return json_util.dumps(response), 200, {'Content-Type': 'application/json'} + + except Exception as e: + current_app.logger.error(f"Error fetching team paths: {str(e)}", exc_info=True) + return jsonify({"error": "Failed to fetch team path data."}), 500 diff --git a/app/scout/routes/pit.py b/app/scout/routes/pit.py new file mode 100644 index 0000000..3362fb8 --- /dev/null +++ b/app/scout/routes/pit.py @@ -0,0 +1,167 @@ +"""Pit scouting: list, add, edit, delete.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from bson import ObjectId +from flask import current_app, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from . import common +from .common import actor_name, scouting_bp + + +@scouting_bp.route("/scouting/pit") +@login_required +# @limiter.limit("50 per minute") +def pit_scouting(): + try: + # Update to use filtered pit scouting data + pit_data_list = list(common.scouting_manager.get_all_pit_scouting( + current_user.teamNumber, + current_user.get_id() + )) + current_app.logger.info(f"Successfully fetched pit scouting data {pit_data_list} for user {actor_name(current_user)}") + return render_template("scouting/pit-scouting.html", pit_data=pit_data_list) + except Exception as e: + current_app.logger.error(f"Error fetching pit scouting data: {str(e)}", exc_info=True) + flash("An internal error has occurred.", "error") + return render_template("scouting/pit-scouting.html", pit_data=[]) + + +@scouting_bp.route("/scouting/pit/add", methods=["GET", "POST"]) +@login_required +# @limiter.limit("15 per minute") +def pit_scouting_add(): + if request.method == "POST": + try: + current_app.logger.info(f"Scouting.add Form Details {request.form}") + # Process form data + pit_data = { + "team_number": int(request.form.get("team_number")), + "scouter_id": current_user.id, + + # Drive base information + "drive_type": { + "swerve": "swerve" in request.form.getlist("drive_type"), + "tank": "tank" in request.form.getlist("drive_type"), + "other": request.form.get("drive_type_other", "") + }, + "swerve_modules": request.form.get("swerve_modules", ""), + + # Mechanisms + "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), + "shooter_type": request.form.get("shooter_type", ""), + "shooter_notes": request.form.get("shooter_notes", ""), + "hopper_notes": request.form.get("hopper_notes", ""), + "intake_notes": request.form.get("intake_notes", ""), + + # Autonomous + "autonomous_capabilities": { + "has_auto": request.form.get("has_auto") == "true", + "num_routes": int(request.form.get("auto_routes", 0) or 0) if request.form.get("has_auto") == "true" else 0, + "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", + "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" + }, + + # Driver Experience + "driver_experience": { + "years": int(request.form.get("driver_years", 0) if not (request.form.get("driver_years") == '') else 0), + "notes": request.form.get("driver_notes", "") + }, + + # General Notes + "notes": request.form.get("notes", ""), + + # Timestamps + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc) + } + + # Add to database + if common.scouting_manager.add_pit_scouting(pit_data): + current_app.logger.info(f"Successfully added pit scouting data {pit_data} for user {actor_name(current_user)}") + flash("Pit scouting data added successfully!", "success") + return redirect(url_for("scouting.pit_scouting")) + else: + current_app.logger.info(f"Failed to add pit scouting data {pit_data} for user {actor_name(current_user)}") + flash("Error adding pit scouting data. Please try again.", "error") + except Exception as e: + flash("An error occurred while adding pit scouting data.", "error") + current_app.logger.error(f"Error adding pit scouting data: {str(e)}", exc_info=True) + return redirect(url_for("scouting.pit_scouting")) + + return render_template("scouting/pit-scouting-add.html") + + +@scouting_bp.route("/scouting/pit/edit/", methods=["GET", "POST"]) +@login_required +# @limiter.limit("10 per minute") +def pit_scouting_edit(entry_id): + pit_data = common.scouting_manager.get_pit_scouting_by_id(entry_id) + if not pit_data: + flash("Pit scouting data not found", "error") + return redirect(url_for("scouting.pit_scouting")) + + if str(pit_data["scouter_id"]) != current_user.get_id(): + flash("You don't have permission to edit this data", "error") + return redirect(url_for("scouting.pit_scouting")) + + if request.method == "POST": + try: + current_app.logger.info(f"Scouting.edit Form Details {request.form}") + data = { + "team_number": int(request.form["team_number"]), + "scouter_id": ObjectId(current_user.get_id()), + "drive_type": { + "swerve": "swerve" in request.form.getlist("drive_type"), + "tank": "tank" in request.form.getlist("drive_type"), + "other": request.form.get("drive_type_other", "") + }, + "swerve_modules": request.form.get("swerve_modules", ""), + + # Mechanisms + "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), + "shooter_type": request.form.get("shooter_type", ""), + "shooter_notes": request.form.get("shooter_notes", ""), + "hopper_notes": request.form.get("hopper_notes", ""), + "intake_notes": request.form.get("intake_notes", ""), + + "autonomous_capabilities": { + "has_auto": request.form.get("has_auto") == "true", + "num_routes": int(request.form.get("auto_routes", 0)) if request.form.get("has_auto") == "true" else 0, + "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", + "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" + }, + "driver_experience": { + "years": int(request.form.get("driver_years", 0)), + "notes": request.form.get("driver_notes", "") + }, + "notes": request.form.get("notes", ""), + "updated_at": datetime.now(timezone.utc) + } + + if common.scouting_manager.update_pit_scouting(entry_id, data, current_user.get_id()): + current_app.logger.info(f"Successfully updated pit scouting data {data} for user {actor_name(current_user)}") + flash("Pit scouting data updated successfully", "success") + return redirect(url_for("scouting.pit_scouting")) + else: + current_app.logger.info(f"Failed to update pit scouting data {data} for user {actor_name(current_user)}") + flash("Error updating pit scouting data", "error") + except Exception as e: + current_app.logger.info(f"Error updating pit scouting data {data} for user {actor_name(current_user)} {str(e)}", exc_info=True) + flash("An internal error has occurred.", "error") + + return render_template("scouting/pit-scouting-edit.html", entry=pit_data) + + +@scouting_bp.route("/scouting/pit/delete/") +@login_required +def pit_scouting_delete(entry_id): + if common.scouting_manager.delete_pit_scouting(entry_id, current_user.get_id()): + current_app.logger.info(f"Successfully deleted pit scouting entry {entry_id} for user {actor_name(current_user)}") + flash("Pit scouting data deleted successfully", "success") + else: + current_app.logger.info(f"Failed to delete pit scouting entry {entry_id} for user {actor_name(current_user)}") + flash("Error deleting pit scouting data", "error") + return redirect(url_for("scouting.pit_scouting")) diff --git a/app/scout/routes/records.py b/app/scout/routes/records.py new file mode 100644 index 0000000..e4974c8 --- /dev/null +++ b/app/scout/routes/records.py @@ -0,0 +1,318 @@ +"""Match scouting records: add, list, edit, delete, and the per-match view.""" +from __future__ import annotations + +import json +from datetime import datetime + +from bson import ObjectId +from flask import (current_app, flash, jsonify, redirect, render_template, + request, url_for) +from flask_login import current_user, login_required + +from app.utils import handle_route_errors + +from ..match13_fuel import FUEL_FIELDS, zero_scored_fuel +from ..TBA import TBAInterface +from . import common +from .common import (SCOUTER_LOOKUP, TELEOP_SHIFT_FIELDS, actor_name, + fuel_total, scouting_bp, team_access_match) + +SCOUTING_PAGE_SIZE = 20 + + +def _user_team(): + """The current user's Team, or None.""" + if not current_user.teamNumber: + return None + if team_doc := common.scouting_manager.db.teams.find_one({"team_number": current_user.teamNumber}): + from app.models import Team + return Team.create_from_db(team_doc) + return None + + +@scouting_bp.route("/scouting/add", methods=["GET", "POST"]) +@login_required +# @limiter.limit("15 per minute") +@handle_route_errors +def add(): + if request.method != "POST": + # Get current events only + tba = TBAInterface() + year = datetime.now().year + events = tba.get_current_events(year, include_test_data=True, background=True) or {} + + return render_template("scouting/add.html", + events=events, + event_matches={}) # Empty dict + + data = request.get_json() if request.is_json else request.form.to_dict() + + if "auto_path" in data: + try: + if isinstance(data["auto_path"], str): + if data["auto_path"].strip(): + data["auto_path"] = json.loads(data["auto_path"]) + else: + data["auto_path"] = [] + except json.JSONDecodeError: + flash("Invalid path coordinates format", "error") + return redirect(url_for("scouting.home")) + + use_estimate = zero_scored_fuel(data) + for field in FUEL_FIELDS: + if data.get(field) in (None, ""): + data[field] = 0 + success, message = common.scouting_manager.add_scouting_data(data, current_user.get_id()) + service = current_app.extensions.get("match13_fuel") + if success and use_estimate and service: + try: + service.enroll(message, data) + except Exception: + current_app.logger.exception("Saved scouting record; could not enroll its fuel estimate") + current_app.logger.info(f"Tried to add scouting data ({success}) {data} for user {actor_name(current_user)} - {message}") + current_app.logger.info(f"Scouting.add Form Details {request.form} - {message}") + + if success: + flash("Team data added successfully", "success") + else: + flash(f"Error adding data: {message}", "error") + + return redirect(url_for("scouting.home")) + + +@scouting_bp.route("/scouting/list") +@scouting_bp.route("/scouting") +# @limiter.limit("30 per minute") +@login_required +def home(): + try: + page = max(1, request.args.get("page", 1, type=int)) if request.args.get("partial") == "1" else 1 + result = common.scouting_manager.get_all_scouting_data( + current_user.teamNumber, current_user.get_id(), page=page, + page_size=SCOUTING_PAGE_SIZE, search=request.args.get("search", ""), + filter_type=request.args.get("filter", "team")) + team_data, total = result["records"], result["total"] + service = current_app.extensions.get("match13_fuel") + if service: + statuses = service.statuses(record["_id"] for record in team_data) + for record in team_data: + record["fuel_estimate"] = statuses.get(str(record["_id"])) + + team = _user_team() + has_more = page * SCOUTING_PAGE_SIZE < total + if request.args.get("partial") == "1": + return jsonify(html=render_template("scouting/_match_list.html", team_data=team_data, team=team), + total=total, has_more=has_more, next_page=page + 1) + return render_template("scouting/list.html", team_data=team_data, team=team, + total=total, has_more=has_more) + except Exception as e: + current_app.logger.error(f"Error fetching scouting data: {str(e)}", exc_info=True) + flash("Unable to fetch scouting data. Please try again later.", "error") + if request.args.get("partial") == "1": + return jsonify(error="Unable to load matches. Please try again."), 503 + return render_template("scouting/list.html", team_data=[], total=0, has_more=False) + + +@scouting_bp.route("/api/fuel_estimates", methods=["POST"]) +@login_required +def fuel_estimates(): + payload = request.get_json(silent=True) or {} + ids = payload.get("ids") if isinstance(payload, dict) else None + if not isinstance(ids, list) or len(ids) > 100 or any(not isinstance(id, str) or not ObjectId.is_valid(id) for id in ids): + return jsonify(error="Provide up to 100 valid record IDs."), 400 + service = current_app.extensions.get("match13_fuel") + if not service or not ids: + return jsonify({}) + statuses = service.statuses(ids, include_manual=True) + if not statuses: + return jsonify({}) + records = common.scouting_manager.db.team_data.aggregate([ + {"$match": {"_id": {"$in": [ObjectId(id) for id in statuses]}}}, + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, team_access_match(current_user), + {"$project": {"_id": 1, **dict.fromkeys(FUEL_FIELDS, 1)}}, + ]) + return jsonify({str(record["_id"]): {**statuses[str(record["_id"])], + "fuel": {field: record.get(field, 0) for field in FUEL_FIELDS}} for record in records}) + + +@scouting_bp.route("/scouting/edit/", methods=["GET", "POST"]) +# @limiter.limit("15 per minute") +@login_required +def edit(id): + try: + team_data = common.scouting_manager.get_team_data(id, current_user.get_id()) + + if not team_data: + flash("Team data not found", "error") + return redirect(url_for("scouting.home")) + + # Check if the current user is on the same team as the scouter + current_team = current_user.teamNumber + scouter_team = team_data.scouter_team + + # Allow access only if user is the original scouter or on the same team + if current_user.get_id() != str(team_data.scouter_id) and (not current_team or not scouter_team or str(current_team) != str(scouter_team)): + flash("Access denied: You can only edit scouting data from your own team", "error") + return redirect(url_for("scouting.home")) + + if request.method == "POST": + data = request.form.to_dict() + current_app.logger.info(f"Scouting.edit Form Details {request.form}") + # Add edit tracking - record who made the edit + data['last_edited_by'] = current_user.get_id() + data['last_edited_at'] = datetime.now().isoformat() + + # Convert the drawing coordinates from string to JSON if present + if "auto_path_coords" in data and isinstance(data["auto_path_coords"], str): + try: + json.loads(data["auto_path_coords"]) # Validate JSON + except json.JSONDecodeError: + flash("Invalid path coordinates format", "error") + return render_template("scouting/edit.html", team_data=team_data) + + service = current_app.extensions.get("match13_fuel") + save = lambda: common.scouting_manager.update_team_data(id, data, current_user.get_id()) + saved = service.manual_update(id, save, data) if service else save() + if saved: + flash("Data updated successfully", "success") + current_app.logger.info(f"Successfully updated scouting data {data} for user {actor_name(current_user)}") + return redirect(url_for("scouting.home")) + + current_app.logger.info(f"Failed to update scouting data {data} for user {actor_name(current_user)}") + flash("Unable to update data", "error") + + service = current_app.extensions.get("match13_fuel") + if service: + team_data.fuel_estimate = service.statuses([id]).get(id) + return render_template("scouting/edit.html", team_data=team_data) + except Exception as e: + current_app.logger.error(f"Error in edit_scouting_data: {str(e)}", exc_info=True) + flash("An error occurred while processing your request", "error") + return redirect(url_for("scouting.home")) + + +@scouting_bp.route("/scouting/delete/") +# @limiter.limit("10 per minute") +@login_required +def delete(id): + try: + # Get information about the record before deleting for better error messages + team_data = common.scouting_manager.get_team_data(id, current_user.get_id()) + if not team_data: + flash("Record not found", "error") + return redirect(url_for("scouting.home")) + + # Attempt to delete + if common.scouting_manager.delete_team_data(id, current_user.get_id()): + current_app.logger.info(f"Successfully deleted scouting data {id} for user {actor_name(current_user)}") + flash("Record deleted successfully", "success") + elif team_data.scouter_id == current_user.get_id(): + flash("Error deleting your record. Please try again.", "error") + elif current_user.teamNumber and team_data.scouter_team == current_user.teamNumber: + if not (team := _user_team()): + current_app.logger.info(f"Permission denied: Team not found {id} for user {actor_name(current_user)}") + flash("Permission denied: Team not found", "error") + elif not team.is_admin(current_user.get_id()): + current_app.logger.info(f"Permission denied: You must be a team admin to delete other members' records {id} for user {actor_name(current_user)}") + flash("Permission denied: You must be a team admin to delete other members' records", "error") + # Try to delete again with admin override + elif common.scouting_manager.delete_team_data(id, current_user.get_id(), admin_override=True): + current_app.logger.info(f"Successfully deleted scouting data {id} for user {actor_name(current_user)} (admin)") + flash("Record deleted successfully (admin)", "success") + else: + current_app.logger.info(f"Failed to delete scouting data {id} for user {actor_name(current_user)} (admin)") + flash("Error deleting team member's record. Please try again.", "error") + else: + current_app.logger.info(f"Permission denied: You can only delete records from your own team {id} for user {actor_name(current_user)}") + flash("Permission denied: You can only delete records from your own team", "error") + except Exception as e: + current_app.logger.error(f"Delete error: {str(e)}", exc_info=True) + flash("An internal error has occurred.", "error") + return redirect(url_for("scouting.home")) + + +def _alliance_fuel(teams: list[dict]) -> dict: + return { + "auto": sum(t["auto_fuel"] for t in teams), + "transition": sum(t["transition_fuel"] for t in teams), + "teleop": sum(sum(t[field] for field in TELEOP_SHIFT_FIELDS) for t in teams), + "endgame": sum(t["endgame_fuel"] for t in teams), + "total": sum(fuel_total(t) for t in teams) + } + + +def _match_team_rows(teams: list[dict], statuses: dict) -> list[dict]: + return [{ + "number": t["number"], + "fuel_estimate": statuses.get(str(t["id"])), + "ferried_fuel": t["ferried_fuel"], + "fuel_total": fuel_total(t), + "climb_level": t["climb_level"], + "climb_success": t["climb_success"], + "auto_climb": t["auto_climb"] + } for t in teams] + + +@scouting_bp.route("/scouting/matches") +# @limiter.limit("30 per minute") +@login_required +def matches(): + try: + pipeline = [ + SCOUTER_LOOKUP, + {"$unwind": "$scouter"}, + team_access_match(current_user), + {"$group": { + "_id": { + "event": "$event_code", + "match": "$match_number" + }, + "teams": { + "$push": { + "id": "$_id", + "number": "$team_number", + "alliance": "$alliance", + + # Fuel stats + "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, + "auto_climb": {"$ifNull": ["$auto_climb", False]}, + "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, + "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, + "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, + "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, + "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, + "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, + + "climb_level": {"$ifNull": ["$climb_level", 0]}, + "climb_type": "$climb_type", + "climb_success": "$climb_success" + } + }, + }} + ] + + match_data = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + service = current_app.extensions.get("match13_fuel") + statuses = service.statuses(t["id"] for match in match_data for t in match["teams"]) if service else {} + matches = [] + + for match in match_data: + red_teams = [t for t in match["teams"] if t["alliance"] == "red"] + blue_teams = [t for t in match["teams"] if t["alliance"] == "blue"] + matches.append({ + "event_code": match["_id"]["event"], + "match_number": match["_id"]["match"], + "red_teams": _match_team_rows(red_teams, statuses), + "blue_teams": _match_team_rows(blue_teams, statuses), + "red_fuel": _alliance_fuel(red_teams), + "blue_fuel": _alliance_fuel(blue_teams) + }) + current_app.logger.info(f"Successfully fetched matches {matches} for user {actor_name(current_user)}") + return render_template("scouting/matches.html", matches=matches) + + except Exception as e: + current_app.logger.error(f"Error fetching matches: {str(e)}", exc_info=True) + flash("An internal error has occurred.", "error") + return render_template("scouting/matches.html", matches=[]) diff --git a/tests/test_match13_fuel.py b/tests/test_match13_fuel.py index 4b691b2..1b190c0 100644 --- a/tests/test_match13_fuel.py +++ b/tests/test_match13_fuel.py @@ -14,7 +14,7 @@ from flask import Flask import requests -from app.scout import routes +from app.scout.routes import common, records from app.scout.match13_fuel import (FUEL_FIELDS, PHASES, FuelEstimateService, apportioned, zero_scored_fuel, estimate_fuel, match_key) @@ -451,8 +451,8 @@ def setUp(self): self.app = Flask(__name__) self.app.config.update(LOGIN_DISABLED=True, SECRET_KEY='test-only') self.app.add_url_rule('/scouting', endpoint='scouting.home', view_func=lambda: 'ok') - self.app.add_url_rule('/scouting/add', view_func=routes.add, methods=['POST']) - self.app.add_url_rule('/api/fuel_estimates', view_func=routes.fuel_estimates, methods=['POST']) + self.app.add_url_rule('/scouting/add', view_func=records.add, methods=['POST']) + self.app.add_url_rule('/api/fuel_estimates', view_func=records.fuel_estimates, methods=['POST']) self.service = MagicMock() self.app.extensions['match13_fuel'] = self.service self.user = SimpleNamespace(get_id=lambda: '6ac99468539dec0f82df2c4e', teamNumber=334, is_authenticated=True, username='tester') @@ -460,7 +460,7 @@ def setUp(self): self.manager.add_scouting_data.return_value = (True, str(ObjectId())) def test_zero_submissions_enroll_even_with_ferry_and_nonzero_scoring_stays_manual(self): - with patch.object(routes, 'current_user', self.user), patch.object(routes, 'scouting_manager', self.manager): + with patch.object(records, 'current_user', self.user), patch.object(common, 'scouting_manager', self.manager): response = self.app.test_client().post('/scouting/add', data={'team_number': '334'}) self.assertEqual(response.status_code, 302) self.service.enroll.assert_called_once() @@ -480,7 +480,7 @@ def test_polling_uses_access_filter_and_rejects_invalid_or_unbounded_ids(self): own, foreign = str(ObjectId()), str(ObjectId()) self.service.statuses.return_value = {own: {'estimated': True}, foreign: {'estimated': True}} self.manager.db.team_data.aggregate.return_value = [{'_id': ObjectId(own), 'auto_fuel': 2}] - with patch.object(routes, 'current_user', self.user), patch.object(routes, 'scouting_manager', self.manager): + with patch.object(records, 'current_user', self.user), patch.object(common, 'scouting_manager', self.manager): client = self.app.test_client() response = client.post('/api/fuel_estimates', json={'ids': [own, foreign]}) self.assertEqual(list(response.json), [own]) diff --git a/tests/test_scout_routes_common.py b/tests/test_scout_routes_common.py new file mode 100644 index 0000000..3105259 --- /dev/null +++ b/tests/test_scout_routes_common.py @@ -0,0 +1,33 @@ +"""Shared scouting route helpers; pure functions, no app or database.""" +from types import SimpleNamespace +import unittest + +from bson import ObjectId + +from app.scout.routes.common import actor_name, fuel_total, team_access_match + + +class CommonHelperTests(unittest.TestCase): + def test_access_match_limits_to_own_records_without_team(self): + user_id = str(ObjectId()) + user = SimpleNamespace(get_id=lambda: user_id, teamNumber=None) + self.assertEqual(team_access_match(user), {"$match": {"scouter._id": ObjectId(user_id)}}) + + def test_access_match_adds_team_records(self): + user_id = str(ObjectId()) + user = SimpleNamespace(get_id=lambda: user_id, teamNumber=334) + self.assertEqual(team_access_match(user), {"$match": {"$or": [ + {"scouter._id": ObjectId(user_id)}, {"scouter.teamNumber": 334}]}}) + + def test_fuel_total_excludes_ferried(self): + record = dict.fromkeys(["auto_fuel", "transition_fuel", "teleop_shift_1_fuel", "teleop_shift_2_fuel", + "teleop_shift_3_fuel", "teleop_shift_4_fuel", "endgame_fuel"], 2) + self.assertEqual(fuel_total({**record, "ferried_fuel": 50}), 14) + + def test_actor_name(self): + self.assertEqual(actor_name(SimpleNamespace(is_authenticated=True, username="a")), "a") + self.assertEqual(actor_name(SimpleNamespace(is_authenticated=False)), "Anonymous") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_scouting_pagination.py b/tests/test_scouting_pagination.py index d7a2771..d39c7c4 100644 --- a/tests/test_scouting_pagination.py +++ b/tests/test_scouting_pagination.py @@ -6,7 +6,7 @@ from bson import ObjectId from flask import Flask from app.scout.scouting_utils import ScoutingManager -from app.scout import routes +from app.scout.routes import common, records class PaginationTests(unittest.TestCase): @@ -52,10 +52,10 @@ def test_existing_callers_still_receive_full_list(self): def test_route_page_boundaries_and_failed_page_retry(self): app = Flask(__name__) app.config.update(LOGIN_DISABLED=True, SECRET_KEY='test-only') - app.add_url_rule('/scouting', view_func=routes.home) + app.add_url_rule('/scouting', view_func=records.home) user = SimpleNamespace(teamNumber=None, get_id=lambda: self.user_id) - with patch.object(routes, 'current_user', user), patch.object(routes, 'scouting_manager', self.manager), \ - patch.object(routes, 'render_template', return_value='
'): + with patch.object(records, 'current_user', user), patch.object(common, 'scouting_manager', self.manager), \ + patch.object(records, 'render_template', return_value='
'): for page, size, more in [(1, 20, True), (2, 20, True), (3, 5, False), (4, 0, False)]: self.manager.get_all_scouting_data = MagicMock(return_value={'records': [{}] * size, 'total': 45}) response = app.test_client().get(f'/scouting?partial=1&page={page}') diff --git a/tests/test_scouting_performance.py b/tests/test_scouting_performance.py index 0da1ced..23119cb 100644 --- a/tests/test_scouting_performance.py +++ b/tests/test_scouting_performance.py @@ -10,7 +10,7 @@ from app.scout import TBA as tba_module from app.scout import team_stats from app.scout.api_cache import PublicAPICache -from app.scout.routes import get_team_epa_opr, get_tba_matches +from app.scout.routes.events import get_team_epa_opr, get_tba_matches class Response: From 9dedb5cb86ae9800506cefbada187cf7835f7ee6 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 10:54:22 -0400 Subject: [PATCH 02/23] Consolidate database layer and drop dead helpers All data access already went through the MongoDB singleton via DatabaseManager; remove what bypassed or duplicated it: - drop flask_pymongo (two unused PyMongo clients/pools in app.py and the auth blueprint) and its requirement - drop legacy get_database_connection / release_connection / force_close_connection / mark_db_accessed and the unused db_accessed flag; DBManager alias becomes the DatabaseManager class - drop app.db_managers and app.user_manager (written, never read) - remove the create_app collection bootstrap; each manager already ensures its own collections (TeamManager now owns "assignments"). Fresh databases now get the team_data/pit_scouting indexes the bootstrap used to pre-empt - remove teardown hook that closed the global connection when an exception escaped the error handlers - auth: remove dead save/send_profile_picture and local copies of allowed_file / async_route; the stricter is_safe_url moves to utils (auth was its only caller) with a test Co-Authored-By: Claude Opus 5.5 --- app/app.py | 54 ++--------------------- app/auth/routes.py | 87 ++----------------------------------- app/notifications/routes.py | 7 +-- app/scout/routes/common.py | 4 -- app/team/routes.py | 5 --- app/team/team_utils.py | 8 ++-- app/utils.py | 52 +++++----------------- requirements.txt | 1 - tests/test_safe_url.py | 21 +++++++++ 9 files changed, 43 insertions(+), 196 deletions(-) create mode 100644 tests/test_safe_url.py diff --git a/app/app.py b/app/app.py index 3a257b0..6e2d5b2 100644 --- a/app/app.py +++ b/app/app.py @@ -8,20 +8,14 @@ send_from_directory, url_for) from flask_cors import CORS from flask_login import LoginManager, current_user -from flask_pymongo import PyMongo from flask_wtf.csrf import CSRFProtect from app.auth.auth_utils import UserManager -from app.utils import close_mongodb_connection, get_mongodb_instance, limiter +from app.utils import limiter csrf = CSRFProtect() -mongo = PyMongo() login_manager = LoginManager() -# Global variable to control notification thread -notification_thread = None -stop_notification_thread = False - logger = logging.getLogger(__name__) def create_app(): @@ -46,50 +40,17 @@ def create_app(): else: app.logger.info("VAPID keys configured properly.") - mongo.init_app(app) - app.mongo = mongo # csrf.init_app(app) limiter.init_app(app) CORS(app, resources={r"/*": {"origins": "*", "methods": ["GET", "POST", "PUT", "DELETE"]}}) - # Initialize db_managers dictionary to store all database managers for proper cleanup - app.db_managers = {} - - with app.app_context(): - # Get the singleton MongoDB instance - mongodb = get_mongodb_instance(app.config["MONGO_URI"]) - db = mongodb.get_db() - - # Initialize collections - if "users" not in db.list_collection_names(): - db.create_collection("users") - logger.info("Created 'users' collection in MongoDB.") - if "teams" not in db.list_collection_names(): - db.create_collection("teams") - logger.info("Created 'teams' collection in MongoDB.") - if "team_data" not in db.list_collection_names(): - db.create_collection("team_data") - logger.info("Created 'team_data' collection in MongoDB.") - if "pit_scouting" not in db.list_collection_names(): - db.create_collection("pit_scouting") - logger.info("Created 'pit_scouting' collection in MongoDB.") - if "assignments" not in db.list_collection_names(): - db.create_collection("assignments") - logger.info("Created 'assignments' collection in MongoDB.") - if "assignment_subscriptions" not in db.list_collection_names(): - db.create_collection("assignment_subscriptions") - logger.info("Created 'assignment_subscriptions' collection in MongoDB.") - + login_manager.init_app(app) login_manager.login_view = "auth.login" login_manager.login_message_category = "error" - # Initialize UserManager with the singleton connection + # First DatabaseManager opens the singleton connection with MONGO_URI try: - # Create user manager with singleton connection user_manager = UserManager(app.config["MONGO_URI"]) - - # Store in app context for proper cleanup - app.user_manager = user_manager except Exception as e: app.logger.error(f"Failed to initialize UserManager: {e}") raise @@ -185,15 +146,6 @@ def serve_root_service_worker(): def offline(): return render_template('offline.html') - # Handle application shutdown to close the singleton MongoDB connection - @app.teardown_appcontext - def teardown_db_connection(exception=None): - """Close the singleton MongoDB connection only during application shutdown""" - if exception is not None and isinstance(exception, Exception): - # Only close during actual application shutdown, not regular request teardown - close_mongodb_connection() - logger.info("Closed singleton MongoDB connection during application shutdown") - return app # if __name__ == "__main__": diff --git a/app/auth/routes.py b/app/auth/routes.py index b1888da..e12b5ec 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,107 +1,26 @@ from __future__ import annotations -import asyncio import os -from functools import wraps -from urllib.parse import urljoin, urlparse -from bson import ObjectId from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template, request, send_file, url_for) from flask_login import current_user, login_required, login_user, logout_user -from flask_pymongo import PyMongo -from werkzeug.utils import secure_filename from app.auth.auth_utils import UserManager -from app.utils import (async_route, get_gridfs, handle_route_errors, - is_safe_url, limiter, send_gridfs_file) - -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} - -def allowed_file(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -def save_profile_picture(file): - """Save profile picture to GridFS""" - if file and allowed_file(file.filename): - return get_gridfs().put( - file.stream.read(), - filename=secure_filename(file.filename), - content_type=file.content_type, - ) - return None - -def send_profile_picture(file_id): - """Retrieve profile picture from GridFS""" - try: - # Convert string ID to ObjectId if necessary - if isinstance(file_id, str): - file_id = ObjectId(file_id) - - # Get the file from GridFS - file_data = get_gridfs().get(file_id) - - return send_file( - file_data, - mimetype=file_data.content_type, - download_name=file_data.filename - ) - except Exception as e: - # Log the error and return default profile picture - print(f"Error retrieving profile picture: {e}") - return send_file("static/images/default_profile.png") - - -def run_async(coro): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -def async_route(f): - @wraps(f) - def wrapper(*args, **kwargs): - return run_async(f(*args, **kwargs)) - - return wrapper - +from app.utils import (async_route, handle_route_errors, is_safe_url, limiter, + send_gridfs_file) auth_bp = Blueprint("auth", __name__) user_manager = None -mongo = None @auth_bp.record def on_blueprint_init(state): - global user_manager, mongo + global user_manager app = state.app - mongo = PyMongo(app) # Create the UserManager with the singleton connection user_manager = UserManager(app.config["MONGO_URI"]) - - # Store in app context for proper cleanup - if not hasattr(app, 'db_managers'): - app.db_managers = {} - app.db_managers['auth'] = user_manager - - -def is_safe_url(target): - if not target: - return False - - ref_url = urlparse(request.host_url) - test_url = urlparse(urljoin(request.host_url, target)) - - return ( - test_url.scheme in ('http', 'https') - and ref_url.netloc == test_url.netloc - and all(c not in target for c in ['\\', '//', '..']) - ) @auth_bp.route("/login", methods=["GET", "POST"]) diff --git a/app/notifications/routes.py b/app/notifications/routes.py index 73d3451..da891c5 100644 --- a/app/notifications/routes.py +++ b/app/notifications/routes.py @@ -26,12 +26,7 @@ def on_blueprint_init(state) -> None: vapid_private_key=vapid_private_key, vapid_claims=vapid_claims ) - - # Store in app context for proper cleanup - if not hasattr(app, 'db_managers'): - app.db_managers = {} - app.db_managers['notification'] = notification_manager - + # Start the notification service notification_manager.start_notification_service() diff --git a/app/scout/routes/common.py b/app/scout/routes/common.py index bb2f509..4a9031f 100644 --- a/app/scout/routes/common.py +++ b/app/scout/routes/common.py @@ -36,10 +36,6 @@ def on_blueprint_init(state): # Create ScoutingManager with the singleton connection scouting_manager = ScoutingManager(app.config["MONGO_URI"]) - # Store in app context for proper cleanup - if not hasattr(app, 'db_managers'): - app.db_managers = {} - app.db_managers['scouting'] = scouting_manager try: service = FuelEstimateService( scouting_manager.db, os.getenv("MATCH13_STATE_PATH") or os.path.join(app.instance_path, "match13.sqlite"), diff --git a/app/team/routes.py b/app/team/routes.py index 91dd80d..4e3a50c 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -27,11 +27,6 @@ def on_blueprint_init(state) -> None: # Create TeamManager with the singleton connection team_manager = TeamManager(app.config["MONGO_URI"]) - - # Store in app context for proper cleanup - if not hasattr(app, 'db_managers'): - app.db_managers = {} - app.db_managers['team'] = team_manager @team_bp.route("/join", methods=["GET", "POST"]) @login_required diff --git a/app/team/team_utils.py b/app/team/team_utils.py index dd35cd9..3d9bb97 100644 --- a/app/team/team_utils.py +++ b/app/team/team_utils.py @@ -33,9 +33,11 @@ def __init__(self, mongo_uri: Optional[str] = None) -> None: def _ensure_collections(self) -> None: """Ensure required collections exist""" - if "teams" not in self.db.list_collection_names(): - self.db.create_collection("teams") - logger.info("Created teams collection") + collections = self.db.list_collection_names() + for name in ("teams", "assignments"): + if name not in collections: + self.db.create_collection(name) + logger.info(f"Created {name} collection") def generate_join_code(self) -> str: """Generate a unique 6-character join code""" diff --git a/app/utils.py b/app/utils.py index feb5b06..911d331 100644 --- a/app/utils.py +++ b/app/utils.py @@ -10,7 +10,7 @@ import colorlog from bson import ObjectId from dotenv import load_dotenv -from flask import flash, g, jsonify, render_template, request, send_file +from flask import flash, jsonify, render_template, request, send_file from flask_limiter import Limiter from flask_limiter.util import get_remote_address from gridfs import GridFS @@ -169,27 +169,9 @@ def get_mongodb_instance(mongo_uri=None): _mongodb_instance = MongoDB(mongo_uri) # Initialize GridFS with the singleton connection fs = GridFS(_mongodb_instance.get_db()) - # Mark the database as accessed for the current request - _mark_db_accessed() return _mongodb_instance -def _mark_db_accessed(): - """Mark this request as having accessed the database""" - try: - if hasattr(g, '_get_current_object'): - g.db_accessed = True - except (RuntimeError, ImportError): - # Not in a Flask context - pass - -def close_mongodb_connection(): - """Close the MongoDB connection at application shutdown""" - global _mongodb_instance - if _mongodb_instance: - _mongodb_instance.close() - _mongodb_instance = None - -class DBManager: +class DatabaseManager: """Base database manager that uses the singleton MongoDB connection""" def __init__(self, mongo_uri=None): @@ -198,21 +180,6 @@ def __init__(self, mongo_uri=None): self.client = mongodb.get_client() self.db = mongodb.get_db() -DatabaseManager = DBManager - -def get_database_connection(mongo_uri=None): - """Legacy function for backward compatibility""" - mongodb = get_mongodb_instance(mongo_uri) - return {'client': mongodb.get_client(), 'db': mongodb.get_db()} - -def release_connection(): - """Legacy function for backward compatibility - now a no-op""" - pass - -def force_close_connection(): - """Legacy function for backward compatibility""" - close_mongodb_connection() - def with_mongodb_retry(retries=3, delay=2): """Decorator for retrying MongoDB operations""" def decorator(f): @@ -233,10 +200,6 @@ def wrapper(*args, **kwargs): return wrapper return decorator -def mark_db_accessed(): - """Mark the current request as having accessed the database""" - _mark_db_accessed() - # ============ Route Utilities ============ def async_route(f): @@ -324,10 +287,16 @@ def error_response(message: str = "Error", status_code: int = 400, log_message: # ============ Security Utilities ============ def is_safe_url(target: str) -> bool: - """Verify URL is safe for redirects""" + """Verify URL is a same-host http(s) target safe for redirects""" + if not target: + return False ref_url = urlparse(request.host_url) test_url = urlparse(urljoin(request.host_url, target)) - return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc + return ( + test_url.scheme in ('http', 'https') + and ref_url.netloc == test_url.netloc + and all(c not in target for c in ['\\', '//', '..']) + ) async def check_password_strength(password: str) -> tuple[bool, str]: """ @@ -340,7 +309,6 @@ async def check_password_strength(password: str) -> tuple[bool, str]: def get_gridfs() -> GridFS | None: """Get the GridFS instance""" - global fs if fs is None: get_mongodb_instance() # This will initialize both MongoDB and GridFS return fs \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ab61e5a..b44b55a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ Flask-Login python-dotenv aiohttp pymongo -flask_pymongo waitress Flask-WTF requests diff --git a/tests/test_safe_url.py b/tests/test_safe_url.py new file mode 100644 index 0000000..db1b13c --- /dev/null +++ b/tests/test_safe_url.py @@ -0,0 +1,21 @@ +"""Login redirect target validation; request context only, no database.""" +import unittest + +from flask import Flask + +from app.utils import is_safe_url + + +class SafeUrlTests(unittest.TestCase): + def test_only_same_host_relative_targets_are_safe(self): + with Flask(__name__).test_request_context(base_url="https://castle.test/"): + for target in ["/scouting", "/team/view/334?tab=1"]: + self.assertTrue(is_safe_url(target), target) + # Absolute URLs are rejected even on the same host (they contain "//") + for target in ["", None, "https://castle.test/leaderboard", "https://evil.test/", "//evil.test/", "/\\evil.test", "/a/../b", + "javascript:alert(1)"]: + self.assertFalse(is_safe_url(target), target) + + +if __name__ == '__main__': + unittest.main() From a2edd3662a74615d6be6c28a88e9cb8ebfe6488f Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:31:18 -0400 Subject: [PATCH 03/23] Make managers and routes synchronous The auth, team and notification managers declared async methods but only ran blocking pymongo calls, and every route paid an asyncio.run per request to drive them. Everything is now plain synchronous code: - async def/await removed from managers and routes; async_route and the flask[async] extra are gone - with_mongodb_retry now actually retries: wrapping a coroutine function returned the coroutine immediately, so ConnectionFailure / ServerSelectionTimeoutError were never caught (27 methods) - /api/search uses requests (10s timeout) instead of aiohttp, which is dropped from requirements - remove the fire-and-forget instant assignment notification in create_or_update_assignment: it called attributes that do not exist (self.mongo_uri, self.get_connection, existing_connection=) and the task was cancelled when asyncio.run returned, so it never sent anything. A working version is tracked as a separate fix. Co-Authored-By: Claude Opus 5.5 --- app/auth/auth_utils.py | 22 ++--- app/auth/routes.py | 31 +++---- app/notifications/notification_manager.py | 8 +- app/notifications/routes.py | 17 ++-- app/scout/routes/lighthouse.py | 34 +++---- app/team/routes.py | 104 +++++++++------------ app/team/team_utils.py | 105 ++++++++-------------- app/utils.py | 10 +-- requirements.txt | 3 +- 9 files changed, 129 insertions(+), 205 deletions(-) diff --git a/app/auth/auth_utils.py b/app/auth/auth_utils.py index 318c4b6..8f7dde6 100644 --- a/app/auth/auth_utils.py +++ b/app/auth/auth_utils.py @@ -15,7 +15,7 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -async def check_password_strength(password): +def check_password_strength(password): """ Check if password meets minimum requirements: - At least 8 characters @@ -37,7 +37,7 @@ def _ensure_collections(self) -> None: logger.info("Created users collection") @with_mongodb_retry(retries=3, delay=2) - async def create_user( + def create_user( self, email, username, @@ -56,7 +56,7 @@ async def create_user( return False, "Username already taken" # Check password strength - password_valid, message = await check_password_strength(password) + password_valid, message = check_password_strength(password) if not password_valid: return False, message @@ -81,7 +81,7 @@ async def create_user( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def authenticate_user(self, login: str, password: str) -> tuple[bool, User | None]: + def authenticate_user(self, login: str, password: str) -> tuple[bool, User | None]: """Authenticate user with retry mechanism""" try: @@ -117,7 +117,7 @@ def get_user_by_id(self, user_id: str) -> User | None: return None @with_mongodb_retry(retries=3, delay=2) - async def update_user_profile(self, user_id: str, updates: dict) -> tuple[bool, str]: + def update_user_profile(self, user_id: str, updates: dict) -> tuple[bool, str]: """Update user profile information""" try: @@ -160,7 +160,7 @@ def get_user_profile(self, username: str) -> User | None: return None @with_mongodb_retry(retries=3, delay=2) - async def update_profile_picture(self, user_id: str, file_id: str) -> tuple[bool, str]: + def update_profile_picture(self, user_id: str, file_id: str) -> tuple[bool, str]: """Update user's profile picture and clean up old one""" try: @@ -203,7 +203,7 @@ def get_profile_picture(self, user_id: str) -> str | None: return None @with_mongodb_retry(retries=3, delay=2) - async def delete_user(self, user_id: str) -> tuple[bool, str]: + def delete_user(self, user_id: str) -> tuple[bool, str]: """Delete a user account and all associated data""" try: @@ -233,7 +233,7 @@ async def delete_user(self, user_id: str) -> tuple[bool, str]: return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def change_password(self, user_id: str, current_password: str, new_password: str) -> tuple[bool, str]: + def change_password(self, user_id: str, current_password: str, new_password: str) -> tuple[bool, str]: """Change user password after verifying current password""" try: @@ -267,7 +267,7 @@ async def change_password(self, user_id: str, current_password: str, new_passwor return False, "Current password is incorrect" # Check new password strength - password_valid, message = await check_password_strength(new_password) + password_valid, message = check_password_strength(new_password) if not password_valid: return False, message @@ -294,7 +294,7 @@ async def change_password(self, user_id: str, current_password: str, new_passwor return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def update_user_settings(self, user_id: str, form_data: dict, profile_picture=None) -> tuple[bool, Optional[str]]: + def update_user_settings(self, user_id: str, form_data: dict, profile_picture=None) -> tuple[bool, Optional[str]]: """Update user settings including profile picture""" try: @@ -325,7 +325,7 @@ async def update_user_settings(self, user_id: str, form_data: dict, profile_pict updates['profile_picture_id'] = file_id if updates: - success, message = await self.update_user_profile(user_id, updates) + success, message = self.update_user_profile(user_id, updates) return success, message return True, "Profile updated successfully" diff --git a/app/auth/routes.py b/app/auth/routes.py index e12b5ec..8234510 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -7,8 +7,7 @@ from flask_login import current_user, login_required, login_user, logout_user from app.auth.auth_utils import UserManager -from app.utils import (async_route, handle_route_errors, is_safe_url, limiter, - send_gridfs_file) +from app.utils import handle_route_errors, is_safe_url, limiter, send_gridfs_file auth_bp = Blueprint("auth", __name__) user_manager = None @@ -25,9 +24,8 @@ def on_blueprint_init(state): @auth_bp.route("/login", methods=["GET", "POST"]) # @limiter.limit("8 per minute") -@async_route @handle_route_errors -async def login(): +def login(): if current_user.is_authenticated: return redirect(url_for("index")) @@ -41,7 +39,7 @@ async def login(): flash("Please provide login and password", "error") return render_template("auth/login.html", form_data={"login": login}) - success, user = await user_manager.authenticate_user(login, password) + success, user = user_manager.authenticate_user(login, password) if success and user: current_app.logger.info(f"Successfully authenticated user {user.username} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") login_user(user, remember=remember) @@ -66,8 +64,7 @@ def forgot_password(): @auth_bp.route("/register", methods=["GET", "POST"]) @limiter.limit("8 per minute") -@async_route -async def register(): +def register(): if current_user.is_authenticated: return redirect(url_for("index")) @@ -91,7 +88,7 @@ async def register(): return render_template("auth/register.html", form_data=form_data) try: - success, message = await user_manager.create_user( + success, message = user_manager.create_user( email=email, username=username, password=password @@ -120,15 +117,14 @@ def logout(): @auth_bp.route("/settings", methods=["GET", "POST"]) @limiter.limit("15 per minute") @login_required -@async_route -async def settings(): +def settings(): try: if request.method == "POST": # Handle form submission form_data = request.form file = request.files.get("profile_picture") - success = await user_manager.update_user_settings( + success = user_manager.update_user_settings( current_user.get_id(), form_data, file @@ -174,8 +170,7 @@ def profile_picture(user_id): @auth_bp.route("/check_username", methods=["POST"]) @login_required -@async_route -async def check_username(): +def check_username(): """Check if a username is available""" try: data = request.get_json() @@ -203,8 +198,7 @@ async def check_username(): @auth_bp.route("/change_password", methods=["POST"]) @limiter.limit("5 per minute") @login_required -@async_route -async def change_password(): +def change_password(): """Change user password""" try: if request.headers.get('X-Requested-With') != 'XMLHttpRequest': @@ -227,7 +221,7 @@ async def change_password(): return jsonify({"success": False, "message": "New password must be different from current password"}), 400 # Change password - success, message = await user_manager.change_password( + success, message = user_manager.change_password( current_user.get_id(), current_password, new_password @@ -250,13 +244,12 @@ async def change_password(): @auth_bp.route("/delete_account", methods=["POST"]) @login_required -@async_route -async def delete_account(): +def delete_account(): """Delete user account""" try: # Create a UserManager with the singleton connection user_manager = UserManager(current_app.config["MONGO_URI"]) - success, message = await user_manager.delete_user(current_user.get_id()) + success, message = user_manager.delete_user(current_user.get_id()) if success: logout_user() diff --git a/app/notifications/notification_manager.py b/app/notifications/notification_manager.py index 723ae8e..195d7b9 100644 --- a/app/notifications/notification_manager.py +++ b/app/notifications/notification_manager.py @@ -284,7 +284,7 @@ def _send_push_notification(self, subscription: AssignmentSubscription) -> bool: return False @with_mongodb_retry() - async def create_subscription(self, user_id: str, team_number: int, + def create_subscription(self, user_id: str, team_number: int, subscription_json: Dict, assignment_id: Optional[str] = None, reminder_time: int = 1440) -> Tuple[bool, str]: @@ -387,7 +387,7 @@ async def create_subscription(self, user_id: str, team_number: int, return False, "An internal error has occurred." @with_mongodb_retry() - async def delete_subscription(self, user_id: str, team_number: int = None, + def delete_subscription(self, user_id: str, team_number: int = None, assignment_id: Optional[str] = None) -> Tuple[bool, str]: """Delete a push notification subscription @@ -422,7 +422,7 @@ async def delete_subscription(self, user_id: str, team_number: int = None, return False, "An internal error has occurred." # @with_mongodb_retry() - # async def test_notification(self, user_id: str) -> Tuple[bool, str]: + # def test_notification(self, user_id: str) -> Tuple[bool, str]: # """Send a test notification to a user # Args: @@ -465,7 +465,7 @@ async def delete_subscription(self, user_id: str, team_number: int = None, # logger.error(f"Error sending test notification: {str(e)}") # return False, "An internal error has occurred." - async def send_instant_assignment_notification(self, assignment_data: Dict, team_number: int) -> None: + def send_instant_assignment_notification(self, assignment_data: Dict, team_number: int) -> None: """Send instant notifications to users when they are assigned to a new assignment Args: diff --git a/app/notifications/routes.py b/app/notifications/routes.py index da891c5..0417037 100644 --- a/app/notifications/routes.py +++ b/app/notifications/routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, current_app, jsonify, request from flask_login import current_user, login_required -from app.utils import async_route, limiter +from app.utils import limiter from .notification_manager import NotificationManager @@ -46,8 +46,7 @@ def get_vapid_public_key(): @notifications_bp.route("/subscribe", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def create_subscription(): +def create_subscription(): """Create or update a push notification subscription""" try: data = request.get_json() @@ -69,7 +68,7 @@ async def create_subscription(): assignment_id = data.get("assignment_id") reminder_time = data.get("reminder_time", 1440) # Default: 1 day - success, message = await notification_manager.create_subscription( + success, message = notification_manager.create_subscription( user_id=current_user.get_id(), team_number=team_number, subscription_json=subscription_json, @@ -100,15 +99,14 @@ async def create_subscription(): @notifications_bp.route("/unsubscribe", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def delete_subscription(): +def delete_subscription(): """Delete a push notification subscription""" try: data = request.get_json() team_number = current_user.teamNumber assignment_id = data.get("assignment_id") if data else None - success, message = await notification_manager.delete_subscription( + success, message = notification_manager.delete_subscription( user_id=current_user.get_id(), team_number=team_number, assignment_id=assignment_id @@ -129,11 +127,10 @@ async def delete_subscription(): # @notifications_bp.route("/test", methods=["POST"]) # @login_required # @limiter.limit("3 per minute") -# @async_route -# async def test_notification(): +# def test_notification(): # """Send a test notification to the current user""" # try: -# success, message = await notification_manager.test_notification( +# success, message = notification_manager.test_notification( # user_id=current_user.get_id() # ) diff --git a/app/scout/routes/lighthouse.py b/app/scout/routes/lighthouse.py index 449dff4..f261099 100644 --- a/app/scout/routes/lighthouse.py +++ b/app/scout/routes/lighthouse.py @@ -1,17 +1,17 @@ """Lighthouse pages and their team compare / search / auto path APIs.""" from __future__ import annotations -import aiohttp +import requests from bson import json_util from flask import current_app, jsonify, render_template, request from flask_login import current_user, login_required -from app.utils import async_route - from ..TBA import TBAInterface from . import common from .common import SCOUTER_LOOKUP, actor_name, scouting_bp, team_access_match +SEARCH_TIMEOUT = 10 # seconds; aiohttp's default was 5 minutes + @scouting_bp.route("/lighthouse") # @limiter.limit("30 per minute") @@ -142,8 +142,7 @@ def compare_teams(): @scouting_bp.route("/api/search") @login_required # @limiter.limit("30 per minute") -@async_route -async def search_teams(): +def search_teams(): query = request.args.get("q", "").strip() if not query: return jsonify([]) @@ -154,25 +153,18 @@ async def search_teams(): # Handle both numeric and text searches if query.isdigit(): - team_key = f"frc{query}" - 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([]) - team = await response.json() + url = f"{tba.base_url}/team/frc{query}" else: # Search by team name/nickname url = f"{tba.base_url}/teams/search/{query}" - async with aiohttp.ClientSession(headers=tba.headers) as session: - async with session.get(url) as response: - if response.status != 200: - return jsonify([]) - teams = await response.json() - if not teams: - return jsonify([]) - team = teams[0] # Take the first match + response = requests.get(url, headers=tba.headers, timeout=SEARCH_TIMEOUT) + if response.status_code != 200: + return jsonify([]) + team = response.json() + if not query.isdigit(): + if not team: + return jsonify([]) + team = team[0] # Take the first match # Get team number from the response team_number = team.get("team_number") diff --git a/app/team/routes.py b/app/team/routes.py index 4e3a50c..bb32be9 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -11,7 +11,7 @@ from werkzeug.utils import secure_filename from app.team.team_utils import TeamManager -from app.utils import (allowed_file, async_route, error_response, get_gridfs, +from app.utils import (allowed_file, error_response, get_gridfs, handle_route_errors, limiter, save_file_to_gridfs, success_response) @@ -30,8 +30,7 @@ def on_blueprint_init(state) -> None: @team_bp.route("/join", methods=["GET", "POST"]) @login_required -@async_route -async def join(): +def join(): try: if current_user.teamNumber: return redirect(url_for("team.manage")) @@ -44,7 +43,7 @@ async def join(): flash("Join code is required", "error") return redirect(url_for("team.join")) - success, result = await team_manager.join_team(current_user.get_id(), join_code) + success, result = team_manager.join_team(current_user.get_id(), join_code) current_app.logger.info(f"Tried to join team ({success}) {join_code} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") if success: @@ -66,8 +65,7 @@ async def join(): @team_bp.route("/create", methods=["GET", "POST"]) @login_required @limiter.limit("15 per minute") -@async_route -async def create(): +def create(): """Handle team creation""" if current_user.teamNumber: return redirect(url_for("team.manage")) @@ -101,7 +99,7 @@ async def create(): ) # Create the team - success, result = await team_manager.create_team( + success, result = team_manager.create_team( team_number=form.team_number.data, creator_id=current_user.id, team_name=form.team_name.data, @@ -128,11 +126,10 @@ async def create(): @team_bp.route("//leave", methods=["GET", "POST"]) @login_required @limiter.limit("5 per minute") -@async_route -async def leave(team_number): +def leave(team_number): """Leave a team""" - success, message = await team_manager.leave_team(current_user.get_id(), team_number) + success, message = team_manager.leave_team(current_user.get_id(), team_number) current_app.logger.info(f"Tried to leave team ({success}) {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -152,10 +149,9 @@ async def leave(team_number): @team_bp.route("//members", methods=["GET"]) @login_required -@async_route -async def get_team_members(team_number): +def get_team_members(team_number): """Get all members of a team""" - members = await team_manager.get_team_members(team_number) + members = team_manager.get_team_members(team_number) return ( jsonify({"success": True, "members": [member.to_dict() for member in members]}), @@ -166,8 +162,7 @@ async def get_team_members(team_number): @team_bp.route("//admin/add", methods=["POST"]) @login_required @limiter.limit("5 per minute") -@async_route -async def add_admin(team_number): +def add_admin(team_number): """Add a new admin to the team""" data = request.get_json() @@ -176,7 +171,7 @@ async def add_admin(team_number): if not user_id: return jsonify({"success": False, "message": "User ID is required"}), 400 - success, message = await team_manager.add_admin( + success, message = team_manager.add_admin( team_number, user_id, current_user.get_id() ) @@ -188,8 +183,7 @@ async def add_admin(team_number): @team_bp.route("//admin/remove", methods=["POST"]) @login_required @limiter.limit("5 per minute") -@async_route -async def remove_admin(team_number): +def remove_admin(team_number): """Remove an admin from the team""" data = request.get_json() @@ -198,7 +192,7 @@ async def remove_admin(team_number): if not user_id: return jsonify({"success": False, "message": "User ID is required"}), 400 - success, message = await team_manager.remove_admin( + success, message = team_manager.remove_admin( team_number, user_id, current_user.get_id() ) current_app.logger.info(f"Tried to remove admin ({success}) {user_id} from team {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -209,13 +203,12 @@ async def remove_admin(team_number): @team_bp.route("//assignments", methods=["POST"]) @login_required # @limiter.limit("10 per minute") -@async_route -async def create_assignment(team_number): +def create_assignment(team_number): """Create a new assignment""" try: data = request.get_json() - success, message = await team_manager.create_or_update_assignment( + success, message = team_manager.create_or_update_assignment( team_number, data, current_user.get_id() ) @@ -257,12 +250,11 @@ def update_assignment_status(assignment_id): @team_bp.route("/assignments//update", methods=["PUT"]) @login_required # @limiter.limit("15 per minute") -@async_route -async def update_assignment(assignment_id): +def update_assignment(assignment_id): """Update assignment""" data = request.get_json() - success, message = await team_manager.update_assignment( + success, message = team_manager.update_assignment( assignment_id, current_user.get_id(), data ) current_app.logger.info(f"Tried to update assignment ({success}) {assignment_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -272,11 +264,10 @@ async def update_assignment(assignment_id): @team_bp.route("/assignments//delete", methods=["DELETE"]) @login_required # @limiter.limit("10 per minute") -@async_route -async def delete_assignment(assignment_id): +def delete_assignment(assignment_id): """Delete assignment""" - success, message = await team_manager.delete_assignment( + success, message = team_manager.delete_assignment( assignment_id, current_user.get_id() ) current_app.logger.info(f"Tried to delete assignment ({success}) {assignment_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -288,14 +279,13 @@ async def delete_assignment(assignment_id): @team_bp.route("/", methods=["GET", "POST"]) @login_required # @limiter.limit("30 per minute") -@async_route -async def manage(team_number=None): +def manage(team_number=None): """Manage team""" if not current_user.teamNumber: return redirect(url_for("team.join")) - success, result = await team_manager.validate_user_team( + success, result = team_manager.validate_user_team( current_user.get_id(), current_user.teamNumber ) current_app.logger.info(f"Tried to validate user team ({success}) {current_user.teamNumber} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -306,8 +296,8 @@ async def manage(team_number=None): team = result # result is the team object if validation succeeded # Get team members and assignments - team_members = await team_manager.get_team_members(team.team_number) - assignments = await team_manager.get_team_assignments(team.team_number) + team_members = team_manager.get_team_members(team.team_number) + assignments = team_manager.get_team_assignments(team.team_number) # Create a dictionary of user IDs to usernames for easier lookup user_dict = {str(member.get_id()): member for member in team_members} @@ -341,11 +331,10 @@ async def manage(team_number=None): @team_bp.route("//user//remove", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def remove_user(team_number, user_id): +def remove_user(team_number, user_id): """Remove a user from the team (admin only)""" - success, message = await team_manager.remove_user( + success, message = team_manager.remove_user( team_number, user_id, current_user.get_id() ) current_app.logger.info(f"Tried to remove user ({success}) {user_id} from team {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -368,11 +357,10 @@ async def remove_user(team_number, user_id): @team_bp.route("//assignments/clear", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def clear_assignments(team_number): +def clear_assignments(team_number): """Clear all assignments for a team (admin only)""" - success, message = await team_manager.clear_assignments( + success, message = team_manager.clear_assignments( team_number, current_user.get_id() ) current_app.logger.info(f"Tried to clear assignments ({success}) for team {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -394,10 +382,9 @@ async def clear_assignments(team_number): @team_bp.route("//delete", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def delete_team(team_number): +def delete_team(team_number): """Delete team (owner only)""" - success, message = await team_manager.delete_team(team_number, current_user.get_id()) + success, message = team_manager.delete_team(team_number, current_user.get_id()) current_app.logger.info(f"Tried to delete team ({success}) {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") if request.headers.get("X-Requested-With") == "XMLHttpRequest": @@ -434,13 +421,12 @@ def team_logo(team_number): @team_bp.route("/assignments//edit", methods=["PUT"]) @login_required @limiter.limit("15 per minute") -@async_route -async def edit_assignment(assignment_id): +def edit_assignment(assignment_id): """Edit an existing assignment""" try: data = request.get_json() - success, result = await team_manager.update_assignment( + success, result = team_manager.update_assignment( assignment_id=assignment_id, user_id=current_user.get_id(), assignment_data=data, @@ -455,17 +441,16 @@ async def edit_assignment(assignment_id): @team_bp.route("/view/") -@async_route -async def view(team_number): +def view(team_number): """Public view of team with limited information""" - team = await team_manager.get_team_by_number(team_number) + team = team_manager.get_team_by_number(team_number) if not team: flash("Team not found", "error") return redirect(url_for("index")) # Get team members - team_members = await team_manager.get_team_members(team_number) + team_members = team_manager.get_team_members(team_number) # Check if current user is a member is_member = False @@ -482,11 +467,10 @@ async def view(team_number): @team_bp.route("//update_logo", methods=["POST"]) @login_required -@async_route @handle_route_errors -async def update_team_logo(team_number): +def update_team_logo(team_number): """Update team logo""" - team = await team_manager.get_team_by_number(team_number) + team = team_manager.get_team_by_number(team_number) if not team or not team.is_admin(current_user.get_id()): return error_response("Unauthorized to update team logo") @@ -498,11 +482,11 @@ async def update_team_logo(team_number): if file.filename == '': return error_response("No file selected") - new_logo_id = await save_file_to_gridfs(file) + new_logo_id = save_file_to_gridfs(file) if not new_logo_id: return error_response("Invalid file type") - success, message = await team_manager.update_team_logo(team_number, new_logo_id) + success, message = team_manager.update_team_logo(team_number, new_logo_id) current_app.logger.info(f"Tried to update team logo ({success}) {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") if not success: get_gridfs().delete(new_logo_id) @@ -513,10 +497,9 @@ async def update_team_logo(team_number): @team_bp.route("//settings") @login_required @limiter.limit("10 per minute") -@async_route -async def settings(team_number): +def settings(team_number): """Team settings page for admins""" - team = await team_manager.get_team_by_number(team_number) + team = team_manager.get_team_by_number(team_number) if not team or not team.is_admin(current_user.get_id()): flash("Unauthorized to access team settings", "error") @@ -529,10 +512,9 @@ async def settings(team_number): @team_bp.route("//update_team_info", methods=["POST"]) @login_required @limiter.limit("10 per minute") -@async_route -async def update_team_info(team_number): +def update_team_info(team_number): """Update team information including logo and description""" - team = await team_manager.get_team_by_number(team_number) + team = team_manager.get_team_by_number(team_number) if not team or not team.is_admin(current_user.get_id()): flash("Unauthorized to update team information", "error") @@ -577,7 +559,7 @@ async def update_team_info(team_number): updates['description'] = description # Update team information - success, message = await team_manager.update_team_info(team_number, updates) + success, message = team_manager.update_team_info(team_number, updates) current_app.logger.info(f"Tried to update team info ({success}) {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") current_app.logger.info(f"Team.update_team_info Form Details {request.form}") flash(message, "success" if success else "error") diff --git a/app/team/team_utils.py b/app/team/team_utils.py index 3d9bb97..e5392c5 100644 --- a/app/team/team_utils.py +++ b/app/team/team_utils.py @@ -8,7 +8,6 @@ from typing import Dict, Optional, Tuple, Union from bson.objectid import ObjectId -from flask import current_app from PIL import Image, ImageDraw, ImageFont from app.models import Assignment, Team, User @@ -46,7 +45,7 @@ def generate_join_code(self) -> str: if not self.db.teams.find_one({"team_join_code": code}): return code - async def _get_team(self, query: Dict) -> Optional[Team]: + def _get_team(self, query: Dict) -> Optional[Team]: """Internal method to fetch team data""" try: if team_data := self.db.teams.find_one(query): @@ -65,7 +64,7 @@ def get_team_by_number_sync(self, team_number: int) -> Optional[Team]: return None @with_mongodb_retry() - async def get_team_by_number(self, team_number: int) -> Optional[Team]: + def get_team_by_number(self, team_number: int) -> Optional[Team]: """Get team by team number""" try: @@ -84,14 +83,14 @@ async def get_team_by_number(self, team_number: int) -> Optional[Team]: return None @with_mongodb_retry(retries=3, delay=2) - async def create_team(self, team_number: int, creator_id: str, + def create_team(self, team_number: int, creator_id: str, team_name: Optional[str] = None, description: Optional[str] = None, logo_id: Optional[str] = None) -> TeamResult: """Create a new team""" try: - if await self.get_team_by_number(team_number): + if self.get_team_by_number(team_number): return False, "Team number already exists" # If no logo provided, create default @@ -131,7 +130,7 @@ async def create_team(self, team_number: int, creator_id: str, return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def join_team(self, user_id: str, team_join_code: str) -> TeamResult: + def join_team(self, user_id: str, team_join_code: str) -> TeamResult: """Add a user to a team using the join code""" try: @@ -166,18 +165,18 @@ async def join_team(self, user_id: str, team_join_code: str) -> TeamResult: return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def leave_team(self, user_id: str, team_number: int) -> TeamResult: + def leave_team(self, user_id: str, team_number: int) -> TeamResult: """Remove a user from a team and remove their admin status""" try: # Get team to check if user is owner - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" # If owner is leaving, transfer ownership first if team.is_owner(user_id): - success, message = await self.transfer_ownership(team_number) + success, message = self.transfer_ownership(team_number) if not success: return False, "Cannot leave team - no available users to transfer ownership to" @@ -203,7 +202,7 @@ async def leave_team(self, user_id: str, team_number: int) -> TeamResult: return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def get_team_members(self, team_number: int) -> list[User]: + def get_team_members(self, team_number: int) -> list[User]: """Get all members of a team""" try: @@ -221,14 +220,14 @@ async def get_team_members(self, team_number: int) -> list[User]: return [] @with_mongodb_retry(retries=3, delay=2) - async def add_admin( + def add_admin( self, team_number: int, user_id: str, admin_id: str ) -> Tuple[bool, str]: """Add a new admin to the team""" try: # Get the team to check owner status - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -258,14 +257,14 @@ async def add_admin( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def remove_admin( + def remove_admin( self, team_number: int, user_id: str, admin_id: str ) -> Tuple[bool, str]: """Remove an admin from the team""" try: # Get the team to check owner status - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -295,11 +294,11 @@ async def remove_admin( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def remove_user(self, team_number: int, user_id: str, admin_id: str) -> TeamResult: + def remove_user(self, team_number: int, user_id: str, admin_id: str) -> TeamResult: """Remove a user from a team (admin action)""" try: - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -336,11 +335,11 @@ async def remove_user(self, team_number: int, user_id: str, admin_id: str) -> Te return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def create_or_update_assignment(self, team_number: int, assignment_data: dict, creator_id: str) -> TeamResult: + def create_or_update_assignment(self, team_number: int, assignment_data: dict, creator_id: str) -> TeamResult: """Create or update an assignment""" try: - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -367,36 +366,6 @@ async def create_or_update_assignment(self, team_number: int, assignment_data: d {"$addToSet": {"assignments": str(result.inserted_id)}}, ) - import asyncio - - from app.notifications.notification_manager import \ - NotificationManager - - async def send_notifications(): - try: - # Reuse the existing database connection - notification_manager = NotificationManager( - self.mongo_uri, - vapid_private_key=current_app.config.get("VAPID_PRIVATE_KEY"), - vapid_claims={"sub": f"mailto:{current_app.config.get('VAPID_CLAIM_EMAIL')}"}, - existing_connection=self.get_connection() - ) - - # Use with context manager pattern to ensure proper cleanup - try: - await notification_manager.send_instant_assignment_notification(assignment, team_number) - finally: - # Don't close the connection here since it's shared - # Just remove the reference to the shared connection - notification_manager.client = None - notification_manager.db = None - - except Exception as e: - logger.error(f"Background notification error: {str(e)}") - - # Start notification task in background - asyncio.create_task(send_notifications()) - return True, "Assignment created successfully" except Exception as e: logger.error(f"Error creating/updating assignment: {str(e)}") @@ -430,7 +399,7 @@ def update_assignment_status( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def get_team_assignments(self, team_number: int) -> list[Assignment]: + def get_team_assignments(self, team_number: int) -> list[Assignment]: """Get all assignments for a team""" try: @@ -441,7 +410,7 @@ async def get_team_assignments(self, team_number: int) -> list[Assignment]: return [] @with_mongodb_retry(retries=3, delay=2) - async def clear_assignments( + def clear_assignments( self, team_number: int, user_id: str ) -> Tuple[bool, str]: """Clear all assignments for a team if user is admin""" @@ -449,7 +418,7 @@ async def clear_assignments( # Get the team to check admin status - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -469,13 +438,13 @@ async def clear_assignments( return False, "An error occurred while clearing assignments" @with_mongodb_retry(retries=3, delay=2) - async def delete_team(self, team_number: int, user_id: str) -> Tuple[bool, str]: + def delete_team(self, team_number: int, user_id: str) -> Tuple[bool, str]: """Delete a team and all associated data if user is owner""" try: # Get the team to check owner status - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -511,7 +480,7 @@ async def delete_team(self, team_number: int, user_id: str) -> Tuple[bool, str]: return False, "An error occurred while deleting the team" @with_mongodb_retry(retries=3, delay=2) - async def delete_assignment( + def delete_assignment( self, assignment_id: str, user_id: str ) -> Tuple[bool, str]: """Delete an assignment if user is admin""" @@ -524,7 +493,7 @@ async def delete_assignment( return False, "Assignment not found" # Get the team to check admin status - team = await self.get_team_by_number(assignment["team_number"]) + team = self.get_team_by_number(assignment["team_number"]) if not team: return False, "Team not found" @@ -544,7 +513,7 @@ async def delete_assignment( return False, "An error occurred while deleting the assignment" @with_mongodb_retry(retries=3, delay=2) - async def update_assignment( + def update_assignment( self, assignment_id: str, user_id: str, assignment_data: Dict ) -> Tuple[bool, str]: """Update an existing assignment""" @@ -555,7 +524,7 @@ async def update_assignment( if not assignment: return False, "Assignment not found" - team = await self.get_team_by_number(assignment["team_number"]) + team = self.get_team_by_number(assignment["team_number"]) if not team: return False, "Team not found" @@ -586,7 +555,7 @@ async def update_assignment( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def reset_user_team(self, user_id: str) -> bool: + def reset_user_team(self, user_id: str) -> bool: """Reset user's team number to None""" try: @@ -602,17 +571,17 @@ async def reset_user_team(self, user_id: str) -> bool: return False @with_mongodb_retry(retries=3, delay=2) - async def validate_user_team(self, user_id: str, team_number: int) -> TeamResult: + def validate_user_team(self, user_id: str, team_number: int) -> TeamResult: """Validate that a user's team exists and update if it doesn't""" try: # Get the user's current team - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) # If team doesn't exist or user is not in the team if not team or user_id not in team.users: logger.warning(f"User {user_id} has invalid team number {team_number}") - await self.reset_user_team(user_id) + self.reset_user_team(user_id) return ( False, "Your team membership needs to be updated. Please join or create a team.", @@ -624,11 +593,11 @@ async def validate_user_team(self, user_id: str, team_number: int) -> TeamResult return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def update_team_logo(self, team_number: int, new_logo_id) -> TeamResult: + def update_team_logo(self, team_number: int, new_logo_id) -> TeamResult: """Update team logo and clean up old one""" try: # Get current team data - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -674,7 +643,7 @@ def cleanup_gridfs(self): return False @with_mongodb_retry(retries=3, delay=2) - async def update_team_info(self, team_number: int, updates: dict) -> TeamResult: + def update_team_info(self, team_number: int, updates: dict) -> TeamResult: """Update team information""" try: # Filter out None values @@ -733,11 +702,11 @@ def create_default_team_logo(self, team_number: int) -> bytes: return buffer.getvalue() @with_mongodb_retry(retries=3, delay=2) - async def transfer_ownership(self, team_number: int) -> TeamResult: + def transfer_ownership(self, team_number: int) -> TeamResult: """Transfer team ownership to next admin or member""" try: - team = await self.get_team_by_number(team_number) + team = self.get_team_by_number(team_number) if not team: return False, "Team not found" @@ -770,7 +739,7 @@ async def transfer_ownership(self, team_number: int) -> TeamResult: return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def get_user_team(self, user_id: str) -> Optional[Team]: + def get_user_team(self, user_id: str) -> Optional[Team]: """Get the team associated with a user""" try: @@ -780,7 +749,7 @@ async def get_user_team(self, user_id: str) -> Optional[Team]: return None # Now get the team using the user's team number - return await self.get_team_by_number(user_data["teamNumber"]) + return self.get_team_by_number(user_data["teamNumber"]) except Exception as e: logger.error(f"Error getting user team: {str(e)}") return None diff --git a/app/utils.py b/app/utils.py index 911d331..79242e3 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,4 +1,3 @@ -import asyncio import logging import os import time @@ -202,13 +201,6 @@ def wrapper(*args, **kwargs): # ============ Route Utilities ============ -def async_route(f): - """Decorator to handle async routes""" - @wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) - return wrapper - def handle_route_errors(f): """Decorator to handle common route errors""" @wraps(f) @@ -298,7 +290,7 @@ def is_safe_url(target: str) -> bool: and all(c not in target for c in ['\\', '//', '..']) ) -async def check_password_strength(password: str) -> tuple[bool, str]: +def check_password_strength(password: str) -> tuple[bool, str]: """ Check if password meets minimum requirements: - At least 8 characters diff --git a/requirements.txt b/requirements.txt index b44b55a..ce1cf0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ -flask[async] +Flask Flask-Login python-dotenv -aiohttp pymongo waitress Flask-WTF From f41a86fa05167a42cd4950ecd76ad95d53eaba92 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:39:33 -0400 Subject: [PATCH 04/23] Move scouting queries into ScoutingManager Route modules no longer build MongoDB pipelines; ScoutingManager owns every query and exposes them by meaning (get_team_leaderboard, get_compare_stats, get_team_records, get_auto_paths, get_match_alliances, get_visible_fuel, get_scouter_leaderboard, ...). - one access_match/_visible_records replaces the per-method copies of the scouter join and team visibility filter - _record_fields is shared by add_scouting_data and update_team_data (update still leaves auto_climb untouched, as before) - PIT_PROJECTION shared by the pit readers - drop unused has_team_data, get_team_stats, get_team_matches, get_auto_paths(team_number), get_pit_scouting, commented-out code, and a debug count_documents that ran on every pit list load Pipelines sent by every scouting route were captured before and after this change and are identical apart from $or clause order. Co-Authored-By: Claude Opus 5.5 --- app/scout/routes/common.py | 16 - app/scout/routes/leaderboards.py | 262 +-------- app/scout/routes/lighthouse.py | 143 +---- app/scout/routes/records.py | 54 +- app/scout/scouting_utils.py | 926 +++++++++++++++--------------- tests/test_match13_fuel.py | 5 + tests/test_scout_routes_common.py | 9 +- tests/test_scouting_pagination.py | 2 +- 8 files changed, 508 insertions(+), 909 deletions(-) diff --git a/app/scout/routes/common.py b/app/scout/routes/common.py index 4a9031f..03b1022 100644 --- a/app/scout/routes/common.py +++ b/app/scout/routes/common.py @@ -4,7 +4,6 @@ import logging import os -from bson import ObjectId from flask import Blueprint from app.scout.scouting_utils import ScoutingManager @@ -20,13 +19,6 @@ TELEOP_SHIFT_FIELDS = ("teleop_shift_1_fuel", "teleop_shift_2_fuel", "teleop_shift_3_fuel", "teleop_shift_4_fuel") -SCOUTER_LOOKUP = {"$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" -}} - @scouting_bp.record def on_blueprint_init(state): @@ -52,14 +44,6 @@ def actor_name(user) -> str: return user.username if user.is_authenticated else 'Anonymous' -def team_access_match(user) -> dict: - """$match stage (after SCOUTER_LOOKUP + $unwind) limiting records to the user's own or their team's.""" - access = {"scouter._id": ObjectId(user.get_id())} - if user.teamNumber: - access = {"$or": [access, {"scouter.teamNumber": user.teamNumber}]} - return {"$match": access} - - def fuel_total(record: dict): """Scored fuel across every phase (excludes ferried fuel).""" return sum(record[field] for field in PHASES) diff --git a/app/scout/routes/leaderboards.py b/app/scout/routes/leaderboards.py index 76933d0..0250db7 100644 --- a/app/scout/routes/leaderboards.py +++ b/app/scout/routes/leaderboards.py @@ -6,202 +6,29 @@ from flask import current_app, render_template, request from flask_login import current_user, login_required +from ..scouting_utils import LEADERBOARD_SORT_FIELDS, SCOUTER_SORT_FIELDS from . import common -from .common import SCOUTER_LOOKUP, actor_name, scouting_bp, team_access_match +from .common import actor_name, scouting_bp + +# EPA/OPR/DPR/CCWM come from TBA/Statbotics, so they are sorted after the aggregate +TBA_METRICS = {'epa', 'opr', 'dpr', 'ccwm'} @scouting_bp.route("/leaderboard") # @limiter.limit("30 per minute") def leaderboard(): try: - MIN_MATCHES = 1 sort_type = request.args.get('sort', 'fuel') selected_event = request.args.get('event', 'all') + manager = common.scouting_manager - # Get available events from scouting data - # Filter by team access: only show events from user's team or user himself - events_pipeline = [ - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - team_access_match(current_user), - # Group by event code to get unique events - {"$group": { - "_id": "$event_code", - "event_name": {"$first": "$event_name"}, - "count": {"$sum": 1} - }}, - {"$sort": {"_id": 1}} - ] - - events = list(common.scouting_manager.db.team_data.aggregate(events_pipeline)) - - # Main pipeline for team data - pipeline = [ - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - team_access_match(current_user) - ] - - # Filter by selected event if not 'all' - if selected_event != 'all': - pipeline.append({"$match": {"event_code": selected_event}}) - - # Continue with the existing aggregation - pipeline.extend([ - {"$group": { - "_id": "$team_number", - "matches_played": {"$sum": 1}, - - # Auto Fuel - "auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, - "transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, - "ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, - - # Teleop Fuel - "teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, - "teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, - "teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, - "teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, - - # Endgame Fuel - "endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, - - # Climb Level - "auto_climb_sum": {"$sum": {"$cond": [{"$eq": ["$auto_climb", True]}, 1, 0]}}, - "climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, - - # Defense Rating - "defense_rating": {"$avg": {"$ifNull": ["$defense_rating", 0]}}, - # Robot Disabled - "robot_disabled_list": {"$push": "$robot_disabled"}, - - "herd_rating": { - "$avg": { - "$cond": [{"$eq": ["$herd_balls", True]}, {"$ifNull": ["$herd_rating", 0]}, None] - } - }, - "herd_count": {"$sum": {"$cond": [{"$eq": ["$herd_balls", True]}, 1, 0]}}, - - "ferrying_rating": { - "$avg": { - "$cond": [{"$eq": ["$ferrying_did", True]}, {"$ifNull": ["$ferrying_rating", 0]}, None] - } - }, - "ferrying_count": {"$sum": {"$cond": [{"$eq": ["$ferrying_did", True]}, 1, 0]}}, - - # Climb stats - "climb_level_1_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 1]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_level_2_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 2]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_level_3_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 3]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, - "climb_attempts": {"$sum": 1}, - "climb_successes": { - "$sum": {"$cond": [{"$eq": ["$climb_success", True]}, 1, 0]} - }, - }}, - {"$match": {"matches_played": {"$gte": MIN_MATCHES}}}, - {"$project": { - "team_number": "$_id", - "matches_played": 1, - - "auto_fuel": {"$round": ["$auto_fuel", 1]}, - "transition_fuel": {"$round": ["$transition_fuel", 1]}, - "ferried_fuel": {"$round": ["$ferried_fuel", 1]}, - "teleop_fuel_total": { - "$add": [ - "$transition_fuel", - "$teleop_shift_1_fuel", - "$teleop_shift_2_fuel", - "$teleop_shift_3_fuel", - "$teleop_shift_4_fuel" - ] - }, - "endgame_fuel": {"$round": ["$endgame_fuel", 1]}, - - # Calculate totals for each category - "total_fuel": { - "$add": [ - "$auto_fuel", - "$transition_fuel", - "$teleop_shift_1_fuel", - "$teleop_shift_2_fuel", - "$teleop_shift_3_fuel", - "$teleop_shift_4_fuel", - "$endgame_fuel" - ] - }, - - "climb_level_avg": {"$round": ["$climb_level", 1]}, - - "auto_climb_pct": { - "$multiply": [ - {"$divide": ["$auto_climb_sum", "$matches_played"]}, - 100 - ] - }, - "climb_l1_pct": { - "$multiply": [ - {"$divide": ["$climb_level_1_success", "$matches_played"]}, - 100 - ] - }, - "climb_l2_pct": { - "$multiply": [ - {"$divide": ["$climb_level_2_success", "$matches_played"]}, - 100 - ] - }, - "climb_l3_pct": { - "$multiply": [ - {"$divide": ["$climb_level_3_success", "$matches_played"]}, - 100 - ] - }, - "climb_success_rate": { - "$multiply": [ - {"$cond": [ - {"$gt": ["$climb_attempts", 0]}, - {"$divide": ["$climb_successes", "$climb_attempts"]}, - 0 - ]}, - 100 - ] - }, - "defense_rating": {"$round": ["$defense_rating", 1]}, - "robot_disabled_list": "$robot_disabled_list", - "herd_rating": {"$round": [{"$ifNull": ["$herd_rating", 0]}, 1]}, - "herd_count": 1, - "ferrying_rating": {"$round": [{"$ifNull": ["$ferrying_rating", 0]}, 1]}, - "ferrying_count": 1 - }} - ]) - - # We cannot sort purely by TBA-fetched metrics natively in MongoDB aggregation - # (EPA/OPR/DPR/CCWM require secondary API calls). Let's process those *after* - # the aggregate completes if one of those sorts is requested. - - tba_metrics = {'epa', 'opr', 'dpr', 'ccwm'} - - if sort_type not in tba_metrics: - sort_field = { - 'fuel': 'total_fuel', - 'auto_fuel': 'auto_fuel', - 'ferried_fuel': 'ferried_fuel', - 'auto_climb': 'auto_climb_pct', - 'climb': 'climb_success_rate', - 'climb_l1': 'climb_l1_pct', - 'climb_l2': 'climb_l2_pct', - 'climb_l3': 'climb_l3_pct', - 'defense': 'defense_rating', - 'herd_rating': 'herd_rating', - 'ferrying_rating': 'ferrying_rating' - }.get(sort_type, 'total_fuel') - - pipeline.append({"$sort": {sort_field: -1}}) - - teams = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + events = manager.get_leaderboard_events(current_user.get_id(), current_user.teamNumber) + sort_field = None if sort_type in TBA_METRICS else LEADERBOARD_SORT_FIELDS.get(sort_type, 'total_fuel') + teams = manager.get_team_leaderboard(current_user.get_id(), current_user.teamNumber, + selected_event, sort_field) current_app.logger.info(f"Successfully fetched leaderboard {teams} for user {actor_name(current_user)}") - if sort_type in tba_metrics: + if sort_type in TBA_METRICS: from app.scout.team_stats import team_stats_snapshot snapshots = team_stats_snapshot([ {"team_number": team["team_number"], "event_code": selected_event} @@ -230,69 +57,12 @@ def scouter_leaderboard(): sort_by = request.args.get('sort', 'match_count') selected_event = request.args.get('event', 'all') selected_team = request.args.get('team', 'all') + manager = common.scouting_manager - # Get list of events for filtering - events_pipeline = [ - {"$group": {"_id": "$event_code"}}, - {"$sort": {"_id": 1}} - ] - events = [evt["_id"] for evt in common.scouting_manager.db.team_data.aggregate(events_pipeline)] - - # Build pipeline to count scouting entries by user - pipeline = [ - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - ] - - # Apply event filter if specified - if selected_event != 'all': - pipeline.append({"$match": {"event_code": selected_event}}) - - # Apply team filter if specified - if selected_team != 'all' and selected_team.isdigit(): - pipeline.append({"$match": {"scouter.teamNumber": int(selected_team)}}) - - # Group by scouter and count - pipeline.extend([ - { - "$group": { - "_id": "$scouter._id", - "username": {"$first": "$scouter.username"}, - "teamNumber": {"$first": "$scouter.teamNumber"}, - "match_count": {"$sum": 1}, - "unique_teams": {"$addToSet": "$team_number"}, - } - }, - { - "$project": { - "username": 1, - "teamNumber": 1, - "match_count": 1, - "unique_teams_count": {"$size": "$unique_teams"}, - } - } - ]) - - # Sort by selected field - sort_field = { - 'match_count': 'match_count', - 'unique_teams': 'unique_teams_count', - }.get(sort_by, 'match_count') - - pipeline.append({"$sort": {sort_field: -1}}) - - # Execute query - scouters = list(common.scouting_manager.db.team_data.aggregate(pipeline)) - - # Get list of all teams for filtering - teams_pipeline = [ - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - {"$match": {"scouter.teamNumber": {"$exists": True, "$ne": None}}}, - {"$group": {"_id": "$scouter.teamNumber"}}, - {"$sort": {"_id": 1}} - ] - teams = [team["_id"] for team in common.scouting_manager.db.team_data.aggregate(teams_pipeline)] + events = manager.get_event_codes() + scouters = manager.get_scouter_leaderboard(selected_event, selected_team, + SCOUTER_SORT_FIELDS.get(sort_by, 'match_count')) + teams = manager.get_scouter_teams() current_app.logger.info(f"Successfully fetched scouter leaderboard {scouters} for user {actor_name(current_user)}") return render_template( diff --git a/app/scout/routes/lighthouse.py b/app/scout/routes/lighthouse.py index f261099..38e68af 100644 --- a/app/scout/routes/lighthouse.py +++ b/app/scout/routes/lighthouse.py @@ -8,7 +8,7 @@ from ..TBA import TBAInterface from . import common -from .common import SCOUTER_LOOKUP, actor_name, scouting_bp, team_access_match +from .common import actor_name, scouting_bp SEARCH_TIMEOUT = 10 # seconds; aiohttp's default was 5 minutes @@ -45,69 +45,27 @@ def compare_teams(): teams_data = {} for team_num in teams: try: - pipeline = [ - {"$match": {"team_number": team_num}}, - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - team_access_match(current_user), - {"$group": { - "_id": "$team_number", - "matches_played": {"$sum": 1}, - - # Fuel Stats - "avg_auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, - "avg_auto_climb": {"$avg": {"$cond": ["$auto_climb", 1, 0]}}, - "avg_transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, - "avg_ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, - "avg_teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, - "avg_teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, - "avg_teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, - "avg_teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, - "avg_endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, - - # Climb Stats - "climb_success_rate": {"$avg": {"$cond": ["$climb_success", 1, 0]}}, - "climb_level_1_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 1]}, 1, 0]}}, - "climb_level_2_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 2]}, 1, 0]}}, - "climb_level_3_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 3]}, 1, 0]}}, - "avg_climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, - - "defense_notes": {"$push": "$defense_notes"}, - "auto_paths": {"$push": { - "path": "$auto_path", - "notes": "$auto_notes", - "match_number": "$match_number" - }}, - "defense_rating": {"$avg": {"$cond": [{"$gt": ["$defense_rating", 0]}, "$defense_rating", None]}}, - "robot_disabled_list": {"$push": "$robot_disabled"}, - "preferred_climb_type": {"$last": "$climb_type"}, - "matches": {"$push": "$$ROOT"} - }}, - {"$match": {"matches_played": {"$gt": 0}}} - ] - - if stats := list( - common.scouting_manager.db.team_data.aggregate(pipeline) - ): + if stats := common.scouting_manager.get_compare_stats( + team_num, current_user.get_id(), current_user.teamNumber): # Calculate total teleop average avg_teleop_total = ( - (stats[0].get("avg_transition_fuel") or 0) + - (stats[0].get("avg_ferried_fuel") or 0) + - (stats[0]["avg_teleop_shift_1_fuel"] or 0) + - (stats[0]["avg_teleop_shift_2_fuel"] or 0) + - (stats[0]["avg_teleop_shift_3_fuel"] or 0) + - (stats[0]["avg_teleop_shift_4_fuel"] or 0) + (stats.get("avg_transition_fuel") or 0) + + (stats.get("avg_ferried_fuel") or 0) + + (stats["avg_teleop_shift_1_fuel"] or 0) + + (stats["avg_teleop_shift_2_fuel"] or 0) + + (stats["avg_teleop_shift_3_fuel"] or 0) + + (stats["avg_teleop_shift_4_fuel"] or 0) ) normalized_stats = { # TODO - "auto_scoring": min((stats[0]["avg_auto_fuel"] or 0) / 10, 1), # Assumes ~10 max auto fuel + "auto_scoring": min((stats["avg_auto_fuel"] or 0) / 10, 1), # Assumes ~10 max auto fuel "teleop_scoring": min(avg_teleop_total / 30, 1), # Assumes ~30 max teleop fuel - "endgame_scoring": min((stats[0]["avg_endgame_fuel"] or 0) / 10, 1), # Assumes ~10 max endgame fuel - "climb_level": min((stats[0]["avg_climb_level"] or 0) / 3, 1), # Max level 3 - "climb_rating": stats[0]["climb_success_rate"], - "defense_rating": (stats[0]["defense_rating"] or 0) / 5 if stats[0].get("defense_rating") is not None else 0 + "endgame_scoring": min((stats["avg_endgame_fuel"] or 0) / 10, 1), # Assumes ~10 max endgame fuel + "climb_level": min((stats["avg_climb_level"] or 0) / 3, 1), # Max level 3 + "climb_rating": stats["climb_success_rate"], + "defense_rating": (stats["defense_rating"] or 0) / 5 if stats.get("defense_rating") is not None else 0 } # Get team info from TBA @@ -120,9 +78,9 @@ def compare_teams(): "city": team_info.get("city"), "state_prov": team_info.get("state_prov"), "country": team_info.get("country"), - "stats": stats[0], + "stats": stats, "normalized_stats": normalized_stats, - "matches": stats[0]["matches"] + "matches": stats["matches"] } except Exception as team_error: @@ -169,45 +127,8 @@ def search_teams(): # Get team number from the response team_number = team.get("team_number") - # Fetch scouting data from our database - pipeline = [ - {"$match": {"team_number": team_number}}, - SCOUTER_LOOKUP, - {"$unwind": {"path": "$scouter"}}, - team_access_match(current_user), - {"$sort": {"event_code": 1, "match_number": 1}}, - { - "$project": { - "_id": {"$toString": "$_id"}, # Convert ObjectId to string - "event_code": 1, - "match_number": 1, - # Fuel Stats - "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, - "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, - "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, - "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, - "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, - "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, - "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, - "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, - - # Climb Stats - "auto_climb": 1, - "climb_level": {"$ifNull": ["$climb_level", 0]}, - "climb_type": 1, - "climb_success": 1, - - "auto_path": 1, - "auto_notes": 1, - "defense_rating": {"$ifNull": ["$defense_rating", 0]}, - "notes": 1, - "scouter_name": "$scouter.username", - "scouter_id": {"$toString": "$scouter._id"} - } - } - ] - - scouting_data = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + scouting_data = common.scouting_manager.get_team_records( + team_number, current_user.get_id(), current_user.teamNumber) # Format response response_data = [{ @@ -242,38 +163,12 @@ def get_team_paths(): try: team_number = int(team_number) - # Build pipeline to get paths for the team - pipeline = [ - {"$match": {"team_number": team_number}}, - SCOUTER_LOOKUP, - {"$unwind": {"path": "$scouter", "preserveNullAndEmptyArrays": True}}, - team_access_match(current_user), - # Only get matches with auto path data - {"$match": {"auto_path": {"$exists": True, "$ne": []}}}, - # Sort by most recent matches first - {"$sort": {"match_number": -1}}, - # Project only the needed fields - {"$project": { - "_id": {"$toString": "$_id"}, - "team_number": 1, - "match_number": 1, - "event_code": 1, - "event_name": 1, - "alliance": 1, - "auto_path": 1, - "auto_notes": 1, - "scouter_name": "$scouter.username", - "scouter_id": {"$toString": "$scouter._id"} - }} - ] - # Get team info from TBA tba = TBAInterface() team_key = f"frc{team_number}" team_info = tba.get_team(team_key) or {} - # Get paths from database - paths = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + paths = common.scouting_manager.get_auto_paths(team_number, current_user.get_id(), current_user.teamNumber) # Format response response = { diff --git a/app/scout/routes/records.py b/app/scout/routes/records.py index e4974c8..e2ee49a 100644 --- a/app/scout/routes/records.py +++ b/app/scout/routes/records.py @@ -14,20 +14,14 @@ from ..match13_fuel import FUEL_FIELDS, zero_scored_fuel from ..TBA import TBAInterface from . import common -from .common import (SCOUTER_LOOKUP, TELEOP_SHIFT_FIELDS, actor_name, - fuel_total, scouting_bp, team_access_match) +from .common import TELEOP_SHIFT_FIELDS, actor_name, fuel_total, scouting_bp SCOUTING_PAGE_SIZE = 20 def _user_team(): """The current user's Team, or None.""" - if not current_user.teamNumber: - return None - if team_doc := common.scouting_manager.db.teams.find_one({"team_number": current_user.teamNumber}): - from app.models import Team - return Team.create_from_db(team_doc) - return None + return common.scouting_manager.get_team(current_user.teamNumber) @scouting_bp.route("/scouting/add", methods=["GET", "POST"]) @@ -126,12 +120,8 @@ def fuel_estimates(): statuses = service.statuses(ids, include_manual=True) if not statuses: return jsonify({}) - records = common.scouting_manager.db.team_data.aggregate([ - {"$match": {"_id": {"$in": [ObjectId(id) for id in statuses]}}}, - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, team_access_match(current_user), - {"$project": {"_id": 1, **dict.fromkeys(FUEL_FIELDS, 1)}}, - ]) + records = common.scouting_manager.get_visible_fuel( + statuses, current_user.get_id(), current_user.teamNumber, FUEL_FIELDS) return jsonify({str(record["_id"]): {**statuses[str(record["_id"])], "fuel": {field: record.get(field, 0) for field in FUEL_FIELDS}} for record in records}) @@ -259,41 +249,7 @@ def _match_team_rows(teams: list[dict], statuses: dict) -> list[dict]: @login_required def matches(): try: - pipeline = [ - SCOUTER_LOOKUP, - {"$unwind": "$scouter"}, - team_access_match(current_user), - {"$group": { - "_id": { - "event": "$event_code", - "match": "$match_number" - }, - "teams": { - "$push": { - "id": "$_id", - "number": "$team_number", - "alliance": "$alliance", - - # Fuel stats - "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, - "auto_climb": {"$ifNull": ["$auto_climb", False]}, - "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, - "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, - "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, - "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, - "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, - "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, - "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, - - "climb_level": {"$ifNull": ["$climb_level", 0]}, - "climb_type": "$climb_type", - "climb_success": "$climb_success" - } - }, - }} - ] - - match_data = list(common.scouting_manager.db.team_data.aggregate(pipeline)) + match_data = common.scouting_manager.get_match_alliances(current_user.get_id(), current_user.teamNumber) service = current_app.extensions.get("match13_fuel") statuses = service.statuses(t["id"] for match in match_data for t in match["teams"]) if service else {} matches = [] diff --git a/app/scout/scouting_utils.py b/app/scout/scouting_utils.py index 60efcf5..254b82e 100644 --- a/app/scout/scouting_utils.py +++ b/app/scout/scouting_utils.py @@ -6,11 +6,113 @@ from bson import ObjectId -from app.models import TeamData +from app.models import Team, TeamData from app.utils import DatabaseManager, with_mongodb_retry logger = logging.getLogger(__name__) +SCOUTER_LOOKUP = {"$lookup": { + "from": "users", + "localField": "scouter_id", + "foreignField": "_id", + "as": "scouter" +}} +UNWIND_SCOUTER = {"$unwind": "$scouter"} + +PIT_PROJECTION = { + "_id": 1, + "team_number": 1, + "drive_type": 1, + "swerve_modules": 1, + "fuel_count_bps": 1, + "shooter_type": 1, + "shooter_notes": 1, + "intake_notes": 1, + "hopper_notes": 1, + "autonomous_capabilities": 1, + "driver_experience": 1, + "notes": 1, + "scouter_id": "$scouter._id", + "scouter_name": "$scouter.username", + "scouter_team": "$scouter.teamNumber" +} + +LEADERBOARD_SORT_FIELDS = { + 'fuel': 'total_fuel', + 'auto_fuel': 'auto_fuel', + 'ferried_fuel': 'ferried_fuel', + 'auto_climb': 'auto_climb_pct', + 'climb': 'climb_success_rate', + 'climb_l1': 'climb_l1_pct', + 'climb_l2': 'climb_l2_pct', + 'climb_l3': 'climb_l3_pct', + 'defense': 'defense_rating', + 'herd_rating': 'herd_rating', + 'ferrying_rating': 'ferrying_rating' +} +SCOUTER_SORT_FIELDS = { + 'match_count': 'match_count', + 'unique_teams': 'unique_teams_count', +} + + +def access_match(user_id, team_number=None) -> dict: + """$match stage (after SCOUTER_LOOKUP + unwind) limiting records to the user's own or their team's.""" + access = {"scouter._id": ObjectId(user_id)} + if team_number: + access = {"$or": [access, {"scouter.teamNumber": team_number}]} + return {"$match": access} + + +def _percent(count_field: str, total_field: str = "$matches_played") -> dict: + return {"$multiply": [{"$divide": [count_field, total_field]}, 100]} + + +def _is_checked(value) -> bool: + return value == "true" or value is True + + +def _record_fields(data: dict) -> dict: + """Scored fields shared by new and edited match records.""" + herd = _is_checked(data.get("herd_balls")) + ferrying = _is_checked(data.get("ferrying_did")) + return { + # Fuel + "auto_fuel": int(data.get("auto_fuel", 0)), + "transition_fuel": int(data.get("transition_fuel", 0)), + "teleop_shift_1_fuel": int(data.get("teleop_shift_1_fuel", 0)), + "teleop_shift_2_fuel": int(data.get("teleop_shift_2_fuel", 0)), + "teleop_shift_3_fuel": int(data.get("teleop_shift_3_fuel", 0)), + "teleop_shift_4_fuel": int(data.get("teleop_shift_4_fuel", 0)), + "endgame_fuel": int(data.get("endgame_fuel", 0)), + "ferried_fuel": int(data.get("ferried_fuel", 0)), + + # Climb + "climb_level": int(data.get("climb_level", 0)), + "climb_type": data.get("climb_type", ""), + "climb_success": bool(data.get("climb_success", False)), + + # Defense + "defense_rating": int(data.get("defense_rating", 1)), + "defense_notes": data.get("defense_notes", ""), + "defense_response_notes": data.get("defense_response_notes", ""), + + # Herd and Ferrying + "herd_balls": herd, + "herd_rating": int(data.get("herd_rating", 5)) if herd else 0, + "ferrying_did": ferrying, + "ferrying_rating": int(data.get("ferrying_rating", 5)) if ferrying else 0, + # Robot Disabled Status + "robot_disabled": data.get("robot_disabled", "None"), + + # Auto + "auto_path": data.get("auto_path", ""), + "auto_notes": data.get("auto_notes", ""), + + # Notes + "notes": data.get("notes", ""), + } + class ScoutingManager(DatabaseManager): def __init__(self, mongo_uri=None): @@ -22,118 +124,43 @@ def _ensure_collections(self) -> None: """Ensure required collections exist""" collections = self.db.list_collection_names() if "team_data" not in collections: - self._create_team_data_collection() + self.db.create_collection("team_data") + self.db.team_data.create_index([("team_number", 1)]) + self.db.team_data.create_index([("scouter_id", 1)]) + logger.info("Created team_data collection and indexes") if "pit_scouting" not in collections: self.db.create_collection("pit_scouting") self.db.pit_scouting.create_index([("team_number", 1)]) self.db.pit_scouting.create_index([("scouter_id", 1)]) logger.info("Created pit_scouting collection and indexes") - - def _create_team_data_collection(self) -> None: - self.db.create_collection("team_data") - self.db.team_data.create_index([("team_number", 1)]) - self.db.team_data.create_index([("scouter_id", 1)]) - logger.info("Created team_data collection and indexes") + + def _visible_records(self, user_id, team_number) -> list[dict]: + """Pipeline prefix: every match record joined with its scouter, limited to what the user may see.""" + return [SCOUTER_LOOKUP, UNWIND_SCOUTER, access_match(user_id, team_number)] + + def get_team(self, team_number) -> Team | None: + if team_number and (team_doc := self.db.teams.find_one({"team_number": team_number})): + return Team.create_from_db(team_doc) + return None + + # ============ Match records ============ @with_mongodb_retry(retries=3, delay=2) def add_scouting_data(self, data: dict, scouter_id: str) -> tuple[bool, str]: - """Add new scouting data with retry mechanism""" + """Add new scouting data with retry mechanism""" try: # Validate team number team_number = int(data["team_number"]) if team_number <= 0: return False, "Invalid team number" - # Lookup to check if this team is already scouted in this match by someone from the same team - # pipeline = [ - # { - # "$match": { - # "event_code": data["event_code"], - # "match_number": data["match_number"], - # "team_number": team_number - # } - # }, - # { - # "$lookup": { - # "from": "users", - # "localField": "scouter_id", - # "foreignField": "_id", - # "as": "scouter" - # } - # }, - # {"$unwind": "$scouter"}, - # { - # "$lookup": { - # "from": "users", - # "localField": "scouter.teamNumber", - # "foreignField": "teamNumber", - # "as": "team_scouters" - # } - # } - # ] - - # if existing_entries := list(self.db.team_data.aggregate(pipeline)): - # current_user = self.db.users.find_one({"_id": ObjectId(scouter_id)}) - # for entry in existing_entries: - # if entry.get("scouter", {}).get("teamNumber") == current_user.get("teamNumber"): - # return False, f"Team {team_number} has already been scouted by your team in match {data['match_number']}" - - # Get existing match data to validate alliance sizes and calculate scores - # match_data = list(self.db.team_data.find({ - # "event_code": data["event_code"], - # "match_number": data["match_number"] - # })) - - # Count teams per alliance - alliance = data.get("alliance", "red") - # red_teams = [m for m in match_data if m["alliance"] == "red"] - # blue_teams = [m for m in match_data if m["alliance"] == "blue"] - - # if (alliance == "red" and len(red_teams) >= 3) or (alliance == "blue" and len(blue_teams) >= 3): - # return False, f"Cannot add more teams to {alliance} alliance (maximum 3)" - - # Process form data team_data = { "team_number": team_number, "event_code": data["event_code"], "match_number": data["match_number"], - "alliance": alliance, - - # Fuel - "auto_fuel": int(data.get('auto_fuel', 0)), - "transition_fuel": int(data.get("transition_fuel", 0)), - "teleop_shift_1_fuel": int(data.get('teleop_shift_1_fuel', 0)), - "teleop_shift_2_fuel": int(data.get('teleop_shift_2_fuel', 0)), - "teleop_shift_3_fuel": int(data.get('teleop_shift_3_fuel', 0)), - "teleop_shift_4_fuel": int(data.get('teleop_shift_4_fuel', 0)), - "endgame_fuel": int(data.get('endgame_fuel', 0)), - "ferried_fuel": int(data.get('ferried_fuel', 0)), - - # Climb + "alliance": data.get("alliance", "red"), "auto_climb": bool(data.get("auto_climb", False)), - "climb_level": int(data.get('climb_level', 0)), - "climb_type": data.get("climb_type", ""), - "climb_success": bool(data.get("climb_success", False)), - - # Defense - "defense_rating": int(data.get("defense_rating", 1)), - "defense_notes": data.get("defense_notes", ""), - "defense_response_notes": data.get("defense_response_notes", ""), - - # Herd and Ferrying - "herd_balls": data.get("herd_balls") == "true" or data.get("herd_balls") is True, - "herd_rating": int(data.get("herd_rating", 5)) if (data.get("herd_balls") == "true" or data.get("herd_balls") is True) else 0, - "ferrying_did": data.get("ferrying_did") == "true" or data.get("ferrying_did") is True, - "ferrying_rating": int(data.get("ferrying_rating", 5)) if (data.get("ferrying_did") == "true" or data.get("ferrying_did") is True) else 0, - # Robot Disabled Status - "robot_disabled": data.get("robot_disabled", "None"), - - # Auto - "auto_path": data.get("auto_path", ""), - "auto_notes": data.get("auto_notes", ""), - - # Notes - "notes": data.get("notes", ""), + **_record_fields(data), # Metadata "scouter_id": ObjectId(scouter_id), @@ -152,46 +179,14 @@ def get_all_scouting_data(self, user_team_number=None, user_id=None, *, page=None, page_size=20, search="", filter_type="team") -> list[dict] | dict: """Read authorized scouting data, optionally returning a page and total.""" try: - # Base pipeline for user lookup - pipeline = [ - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - ] - - # Add match stage for filtering based on team number or user ID - if user_team_number: - # If user has a team number, show data from their team and their own data - pipeline.append({ - "$match": { - "$or": [ - {"scouter.teamNumber": user_team_number}, - {"scouter._id": ObjectId(user_id)} - ] - } - }) - else: - # If user has no team, only show their own data - pipeline.append({ - "$match": { - "scouter._id": ObjectId(user_id) - } - }) - - # Project the needed fields + pipeline = self._visible_records(user_id, user_team_number) pipeline.append({ "$project": { "_id": 1, "team_number": 1, "match_number": 1, "event_code": 1, - + # Fuel Stats "auto_fuel": 1, "transition_fuel": 1, @@ -225,7 +220,7 @@ def get_all_scouting_data(self, user_team_number=None, user_id=None, *, "device_type": 1 } }) - + if page is not None: # Keep the existing visibility rules and storage format. Search # precedes pagination so older records remain discoverable. @@ -254,6 +249,14 @@ def get_all_scouting_data(self, user_team_number=None, user_id=None, *, raise return [] + def get_visible_fuel(self, record_ids, user_id, team_number, fields) -> list[dict]: + """Fuel fields of the given records the user may see.""" + return list(self.db.team_data.aggregate([ + {"$match": {"_id": {"$in": [ObjectId(id) for id in record_ids]}}}, + *self._visible_records(user_id, team_number), + {"$project": {"_id": 1, **dict.fromkeys(fields, 1)}}, + ])) + @with_mongodb_retry(retries=3, delay=2) def get_team_data(self, team_id, scouter_id=None) -> TeamData | None: """Get specific team data with optional scouter verification""" @@ -318,7 +321,7 @@ def update_team_data(self, team_id, data, scouter_id) -> bool: # Check for duplicate scouting records only when identity fields change. # This allows editing existing duplicate records without being blocked. if identity_changed: - pipeline = [ + existing_entries = list(self.db.team_data.aggregate([ { "$match": { "event_code": event_code, @@ -327,18 +330,9 @@ def update_team_data(self, team_id, data, scouter_id) -> bool: "_id": {"$ne": ObjectId(team_id)} # Exclude current entry } }, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"} - ] - - existing_entries = list(self.db.team_data.aggregate(pipeline)) + SCOUTER_LOOKUP, + UNWIND_SCOUTER + ])) current_user = self.db.users.find_one({"_id": ObjectId(scouter_id)}) for entry in existing_entries: @@ -358,10 +352,8 @@ def update_team_data(self, team_id, data, scouter_id) -> bool: # Count teams per alliance alliance = data.get("alliance", "red") - red_teams = [m for m in match_data if m["alliance"] == "red"] - blue_teams = [m for m in match_data if m["alliance"] == "blue"] - - if ((alliance == "red" and len(red_teams) >= 3) or (alliance == "blue" and len(blue_teams) >= 3)) and existing_data.get("alliance") != alliance: + alliance_size = sum(1 for m in match_data if m["alliance"] == alliance) + if alliance in ("red", "blue") and alliance_size >= 3 and existing_data.get("alliance") != alliance: return False updated_data = { @@ -369,41 +361,7 @@ def update_team_data(self, team_id, data, scouter_id) -> bool: "event_code": event_code, "match_number": match_number, "alliance": alliance, - - # Fuel Stats - "auto_fuel": int(data.get("auto_fuel", 0)), - "transition_fuel": int(data.get("transition_fuel", 0)), - "teleop_shift_1_fuel": int(data.get("teleop_shift_1_fuel", 0)), - "teleop_shift_2_fuel": int(data.get("teleop_shift_2_fuel", 0)), - "teleop_shift_3_fuel": int(data.get("teleop_shift_3_fuel", 0)), - "teleop_shift_4_fuel": int(data.get("teleop_shift_4_fuel", 0)), - "endgame_fuel": int(data.get("endgame_fuel", 0)), - "ferried_fuel": int(data.get("ferried_fuel", 0)), - - # Climb Stats - "climb_level": int(data.get("climb_level", 0)), - "climb_type": data.get("climb_type", ""), - "climb_success": bool(data.get("climb_success", False)), - - # Defense - "defense_rating": int(data.get("defense_rating", 1)), - "defense_notes": data.get("defense_notes", ""), - "defense_response_notes": data.get("defense_response_notes", ""), - - # Herd and Ferrying - "herd_balls": data.get("herd_balls") == "true" or data.get("herd_balls") is True, - "herd_rating": int(data.get("herd_rating", 5)) if (data.get("herd_balls") == "true" or data.get("herd_balls") is True) else 0, - "ferrying_did": data.get("ferrying_did") == "true" or data.get("ferrying_did") is True, - "ferrying_rating": int(data.get("ferrying_rating", 5)) if (data.get("ferrying_did") == "true" or data.get("ferrying_did") is True) else 0, - # Robot Disabled Status - "robot_disabled": data.get("robot_disabled", "None"), - - # Auto - "auto_path": data.get("auto_path", ""), - "auto_notes": data.get("auto_notes", ""), - - # Notes - "notes": data.get("notes", ""), + **_record_fields(data), } result = self.db.team_data.update_one( @@ -466,130 +424,330 @@ def delete_team_data(self, team_id: str, user_id: str, admin_override=False) -> logger.error(f"Error deleting team data: {str(e)}", exc_info=True) return False - @with_mongodb_retry(retries=3, delay=2) - def has_team_data(self, team_number: str) -> bool: - """Check if there is any scouting data for a given team number""" - try: - count = self.db.team_data.count_documents({"team_number": int(team_number)}) - return count > 0 - except Exception as e: - logger.error(f"Error checking team data: {str(e)}") - return False + # ============ Analysis over visible match records ============ - @with_mongodb_retry(retries=3, delay=2) - def get_team_stats(self, team_number: str) -> dict | None: - """Get comprehensive stats for a team""" - try: - pipeline = [ - {"$match": {"team_number": int(team_number)}}, - { - "$group": { - "_id": "$team_number", - "matches_played": {"$sum": 1}, - "total_fuel": { - "$sum": { - "$add": [ - {"$ifNull": ["$auto_fuel", 0]}, - {"$ifNull": ["$teleop_shift_1_fuel", 0]}, - {"$ifNull": ["$teleop_shift_2_fuel", 0]}, - {"$ifNull": ["$teleop_shift_3_fuel", 0]}, - {"$ifNull": ["$teleop_shift_4_fuel", 0]}, - {"$ifNull": ["$endgame_fuel", 0]} - ] - } - }, - "avg_climb_level": { - "$avg": {"$ifNull": ["$climb_level", 0]} - }, - "successful_climbs": { - "$sum": {"$cond": ["$climb_success", 1, 0]} - }, - "total_defense": {"$sum": "$defense_rating"}, - "total_points": {"$sum": "$total_points"} + def get_match_alliances(self, user_id, team_number) -> list[dict]: + """Visible records grouped by (event, match), each team's fuel and climb defaulted.""" + return list(self.db.team_data.aggregate([ + *self._visible_records(user_id, team_number), + {"$group": { + "_id": { + "event": "$event_code", + "match": "$match_number" + }, + "teams": { + "$push": { + "id": "$_id", + "number": "$team_number", + "alliance": "$alliance", + + # Fuel stats + "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, + "auto_climb": {"$ifNull": ["$auto_climb", False]}, + "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, + "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, + "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, + "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, + "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, + "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, + + "climb_level": {"$ifNull": ["$climb_level", 0]}, + "climb_type": "$climb_type", + "climb_success": "$climb_success" } - } - ] - - result = list(self.db.team_data.aggregate(pipeline)) - if not result: - return { - "matches_played": 0, - "total_fuel": 0, - "avg_climb_level": 0, - "successful_climbs": 0, - "total_defense": 0, - "total_points": 0 - } - - stats = result[0] - stats.pop("_id") # Remove MongoDB ID - return stats - except Exception as e: - logger.error(f"Error getting team stats: {str(e)}") - return None + }, + }} + ])) + + def get_compare_stats(self, team: int, user_id, team_number) -> dict | None: + """Averages for one team over the user's visible records, or None without data.""" + stats = list(self.db.team_data.aggregate([ + {"$match": {"team_number": team}}, + *self._visible_records(user_id, team_number), + {"$group": { + "_id": "$team_number", + "matches_played": {"$sum": 1}, - @with_mongodb_retry(retries=3, delay=2) - def get_team_matches(self, team_number: str) -> list[dict]: - """Get all match data for a specific team""" - try: - pipeline = [ - {"$match": {"team_number": int(team_number)}}, - {"$sort": {"event_code": 1, "match_number": 1}}, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" + # Fuel Stats + "avg_auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, + "avg_auto_climb": {"$avg": {"$cond": ["$auto_climb", 1, 0]}}, + "avg_transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "avg_ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, + "avg_teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, + "avg_teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, + "avg_teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, + "avg_teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, + "avg_endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, + + # Climb Stats + "climb_success_rate": {"$avg": {"$cond": ["$climb_success", 1, 0]}}, + "climb_level_1_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 1]}, 1, 0]}}, + "climb_level_2_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 2]}, 1, 0]}}, + "climb_level_3_count": {"$sum": {"$cond": [{"$eq": ["$climb_level", 3]}, 1, 0]}}, + "avg_climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, + + "defense_notes": {"$push": "$defense_notes"}, + "auto_paths": {"$push": { + "path": "$auto_path", + "notes": "$auto_notes", + "match_number": "$match_number" + }}, + "defense_rating": {"$avg": {"$cond": [{"$gt": ["$defense_rating", 0]}, "$defense_rating", None]}}, + "robot_disabled_list": {"$push": "$robot_disabled"}, + "preferred_climb_type": {"$last": "$climb_type"}, + "matches": {"$push": "$$ROOT"} + }}, + {"$match": {"matches_played": {"$gt": 0}}} + ])) + return stats[0] if stats else None + + def get_team_records(self, team: int, user_id, team_number) -> list[dict]: + """A team's visible records for search results, oldest match first.""" + return list(self.db.team_data.aggregate([ + {"$match": {"team_number": team}}, + SCOUTER_LOOKUP, + {"$unwind": {"path": "$scouter"}}, + access_match(user_id, team_number), + {"$sort": {"event_code": 1, "match_number": 1}}, + { + "$project": { + "_id": {"$toString": "$_id"}, # Convert ObjectId to string + "event_code": 1, + "match_number": 1, + # Fuel Stats + "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, + "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, + "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, + "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, + "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, + "teleop_shift_4_fuel": {"$ifNull": ["$teleop_shift_4_fuel", 0]}, + "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, + + # Climb Stats + "auto_climb": 1, + "climb_level": {"$ifNull": ["$climb_level", 0]}, + "climb_type": 1, + "climb_success": 1, + + "auto_path": 1, + "auto_notes": 1, + "defense_rating": {"$ifNull": ["$defense_rating", 0]}, + "notes": 1, + "scouter_name": "$scouter.username", + "scouter_id": {"$toString": "$scouter._id"} + } + } + ])) + + def get_auto_paths(self, team: int, user_id, team_number) -> list[dict]: + """A team's visible records that drew an auto path, newest match first.""" + return list(self.db.team_data.aggregate([ + {"$match": {"team_number": team}}, + SCOUTER_LOOKUP, + {"$unwind": {"path": "$scouter", "preserveNullAndEmptyArrays": True}}, + access_match(user_id, team_number), + # Only get matches with auto path data + {"$match": {"auto_path": {"$exists": True, "$ne": []}}}, + {"$sort": {"match_number": -1}}, + {"$project": { + "_id": {"$toString": "$_id"}, + "team_number": 1, + "match_number": 1, + "event_code": 1, + "event_name": 1, + "alliance": 1, + "auto_path": 1, + "auto_notes": 1, + "scouter_name": "$scouter.username", + "scouter_id": {"$toString": "$scouter._id"} + }} + ])) + + def get_leaderboard_events(self, user_id, team_number) -> list[dict]: + """Events in the user's visible records, with record counts.""" + return list(self.db.team_data.aggregate([ + *self._visible_records(user_id, team_number), + {"$group": { + "_id": "$event_code", + "event_name": {"$first": "$event_name"}, + "count": {"$sum": 1} + }}, + {"$sort": {"_id": 1}} + ])) + + def get_team_leaderboard(self, user_id, team_number, event="all", sort_field=None, + min_matches=1) -> list[dict]: + """Per-team averages over visible records; sorted descending by sort_field when given.""" + pipeline = self._visible_records(user_id, team_number) + if event != 'all': + pipeline.append({"$match": {"event_code": event}}) + pipeline.extend([ + {"$group": { + "_id": "$team_number", + "matches_played": {"$sum": 1}, + + # Auto Fuel + "auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, + "transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, + + # Teleop Fuel + "teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, + "teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, + "teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, + "teleop_shift_4_fuel": {"$avg": {"$ifNull": ["$teleop_shift_4_fuel", 0]}}, + + # Endgame Fuel + "endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, + + # Climb Level + "auto_climb_sum": {"$sum": {"$cond": [{"$eq": ["$auto_climb", True]}, 1, 0]}}, + "climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, + + # Defense Rating + "defense_rating": {"$avg": {"$ifNull": ["$defense_rating", 0]}}, + # Robot Disabled + "robot_disabled_list": {"$push": "$robot_disabled"}, + + "herd_rating": { + "$avg": { + "$cond": [{"$eq": ["$herd_balls", True]}, {"$ifNull": ["$herd_rating", 0]}, None] } }, - {"$unwind": "$scouter"} - ] + "herd_count": {"$sum": {"$cond": [{"$eq": ["$herd_balls", True]}, 1, 0]}}, - return list(self.db.team_data.aggregate(pipeline)) - except Exception as e: - logger.error(f"Error getting team matches: {str(e)}") - return [] + "ferrying_rating": { + "$avg": { + "$cond": [{"$eq": ["$ferrying_did", True]}, {"$ifNull": ["$ferrying_rating", 0]}, None] + } + }, + "ferrying_count": {"$sum": {"$cond": [{"$eq": ["$ferrying_did", True]}, 1, 0]}}, + + # Climb stats + "climb_level_1_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 1]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_level_2_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 2]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_level_3_success": {"$sum": {"$cond": [{"$and": [{"$eq": ["$climb_level", 3]}, {"$eq": ["$climb_success", True]}]}, 1, 0]}}, + "climb_attempts": {"$sum": 1}, + "climb_successes": { + "$sum": {"$cond": [{"$eq": ["$climb_success", True]}, 1, 0]} + }, + }}, + {"$match": {"matches_played": {"$gte": min_matches}}}, + {"$project": { + "team_number": "$_id", + "matches_played": 1, + + "auto_fuel": {"$round": ["$auto_fuel", 1]}, + "transition_fuel": {"$round": ["$transition_fuel", 1]}, + "ferried_fuel": {"$round": ["$ferried_fuel", 1]}, + "teleop_fuel_total": { + "$add": [ + "$transition_fuel", + "$teleop_shift_1_fuel", + "$teleop_shift_2_fuel", + "$teleop_shift_3_fuel", + "$teleop_shift_4_fuel" + ] + }, + "endgame_fuel": {"$round": ["$endgame_fuel", 1]}, + + # Calculate totals for each category + "total_fuel": { + "$add": [ + "$auto_fuel", + "$transition_fuel", + "$teleop_shift_1_fuel", + "$teleop_shift_2_fuel", + "$teleop_shift_3_fuel", + "$teleop_shift_4_fuel", + "$endgame_fuel" + ] + }, - @with_mongodb_retry(retries=3, delay=2) - def get_auto_paths(self, team_number: str) -> list[dict]: - """Get all auto paths for a specific team""" - try: - paths = list(self.db.team_data.find( - { - "team_number": int(team_number), - "auto_path": {"$exists": True, "$ne": ""} + "climb_level_avg": {"$round": ["$climb_level", 1]}, + + "auto_climb_pct": _percent("$auto_climb_sum"), + "climb_l1_pct": _percent("$climb_level_1_success"), + "climb_l2_pct": _percent("$climb_level_2_success"), + "climb_l3_pct": _percent("$climb_level_3_success"), + "climb_success_rate": { + "$multiply": [ + {"$cond": [ + {"$gt": ["$climb_attempts", 0]}, + {"$divide": ["$climb_successes", "$climb_attempts"]}, + 0 + ]}, + 100 + ] }, - { - "match_number": 1, - "event_code": 1, - "auto_path": 1 + "defense_rating": {"$round": ["$defense_rating", 1]}, + "robot_disabled_list": "$robot_disabled_list", + "herd_rating": {"$round": [{"$ifNull": ["$herd_rating", 0]}, 1]}, + "herd_count": 1, + "ferrying_rating": {"$round": [{"$ifNull": ["$ferrying_rating", 0]}, 1]}, + "ferrying_count": 1 + }} + ]) + if sort_field: + pipeline.append({"$sort": {sort_field: -1}}) + return list(self.db.team_data.aggregate(pipeline)) + + def get_scouter_leaderboard(self, event="all", scouter_team="all", sort_field="match_count") -> list[dict]: + """Records per scouter across all teams, optionally filtered by event and scouter's team.""" + pipeline = [SCOUTER_LOOKUP, UNWIND_SCOUTER] + if event != 'all': + pipeline.append({"$match": {"event_code": event}}) + if scouter_team != 'all' and scouter_team.isdigit(): + pipeline.append({"$match": {"scouter.teamNumber": int(scouter_team)}}) + pipeline.extend([ + { + "$group": { + "_id": "$scouter._id", + "username": {"$first": "$scouter.username"}, + "teamNumber": {"$first": "$scouter.teamNumber"}, + "match_count": {"$sum": 1}, + "unique_teams": {"$addToSet": "$team_number"}, } - ).sort("match_number", 1)) - - return [ - { - "match_number": path.get("match_number", "Unknown"), - "event_code": path.get("event_code", "Unknown"), - "image_data": path["auto_path"], + }, + { + "$project": { + "username": 1, + "teamNumber": 1, + "match_count": 1, + "unique_teams_count": {"$size": "$unique_teams"}, } - for path in paths - if path.get("auto_path") - ] - except Exception as e: - logger.error(f"Error fetching auto paths for team {team_number}: {str(e)}") - return [] + }, + {"$sort": {sort_field: -1}} + ]) + return list(self.db.team_data.aggregate(pipeline)) + + def get_event_codes(self) -> list: + """Every event code with match records, sorted.""" + return [evt["_id"] for evt in self.db.team_data.aggregate([ + {"$group": {"_id": "$event_code"}}, + {"$sort": {"_id": 1}} + ])] + + def get_scouter_teams(self) -> list: + """Team numbers of every scouter with match records, sorted.""" + return [team["_id"] for team in self.db.team_data.aggregate([ + SCOUTER_LOOKUP, + UNWIND_SCOUTER, + {"$match": {"scouter.teamNumber": {"$exists": True, "$ne": None}}}, + {"$group": {"_id": "$scouter.teamNumber"}}, + {"$sort": {"_id": 1}} + ])] + + # ============ Pit scouting ============ @with_mongodb_retry(retries=3, delay=2) def add_pit_scouting(self, data: dict) -> bool: - """Add new pit scouting data with team validation""" + """Add new pit scouting data""" try: - team_number = int(data["team_number"]) - scouter_id = ObjectId(data["scouter_id"]) # Convert to ObjectId - - # Ensure scouter_id is ObjectId in the data - data["scouter_id"] = scouter_id - + int(data["team_number"]) + data["scouter_id"] = ObjectId(data["scouter_id"]) result = self.db.pit_scouting.insert_one(data) return bool(result.inserted_id) @@ -601,193 +759,25 @@ def add_pit_scouting(self, data: dict) -> bool: def get_pit_scouting_by_id(self, entry_id: str) -> dict | None: """Get a single pit scouting record by its MongoDB _id""" try: - pipeline = [ + result = list(self.db.pit_scouting.aggregate([ {"$match": {"_id": ObjectId(entry_id)}}, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - { - "$unwind": { - "path": "$scouter", - "preserveNullAndEmptyArrays": True - } - }, - { - "$project": { - "_id": 1, - "team_number": 1, - "drive_type": 1, - "swerve_modules": 1, - "fuel_count_bps": 1, - "shooter_type": 1, - "shooter_notes": 1, - "intake_notes": 1, - "hopper_notes": 1, - "autonomous_capabilities": 1, - "driver_experience": 1, - "notes": 1, - "scouter_id": "$scouter._id", - "scouter_name": "$scouter.username", - "scouter_team": "$scouter.teamNumber" - } - } - ] - result = list(self.db.pit_scouting.aggregate(pipeline)) + SCOUTER_LOOKUP, + {"$unwind": {"path": "$scouter", "preserveNullAndEmptyArrays": True}}, + {"$project": PIT_PROJECTION} + ])) return result[0] if result else None except Exception as e: logger.error(f"Error fetching pit scouting data by id: {str(e)}") return None - @with_mongodb_retry(retries=3, delay=2) - def get_pit_scouting(self, team_number: str) -> dict | None: - """Get pit scouting data with scouter information""" - try: - pipeline = [ - { - "$match": { - "team_number": int(team_number) - } - }, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - { - "$unwind": { - "path": "$scouter", - "preserveNullAndEmptyArrays": True - } - }, - { - "$project": { - "_id": 1, - "team_number": 1, - "drive_type": 1, - "swerve_modules": 1, - "fuel_count_bps": 1, - "shooter_type": 1, - "shooter_notes": 1, - "intake_notes": 1, - "hopper_notes": 1, - "autonomous_capabilities": 1, - "driver_experience": 1, - "notes": 1, - "scouter_id": "$scouter._id", - "scouter_name": "$scouter.username", - "scouter_team": "$scouter.teamNumber" - } - } - ] - - result = list(self.db.pit_scouting.aggregate(pipeline)) - return result[0] if result else None - except Exception as e: - logger.error(f"Error fetching pit scouting data: {str(e)}") - return None - @with_mongodb_retry(retries=3, delay=2) def get_all_pit_scouting(self, user_team_number=None, user_id=None) -> list[dict]: """Get all pit scouting data with team-based access control""" try: - logger.info(f"Fetching pit scouting data for user_id: {user_id}, team_number: {user_team_number}") - - # First check if we have any data at all in the collection - total_count = self.db.pit_scouting.count_documents({}) - logger.info(f"Total documents in pit_scouting collection: {total_count}") - - # Log the raw documents for debugging - # raw_docs = list(self.db.pit_scouting.find()) - # for doc in raw_docs: - # logger.info(f"Raw pit scouting document: {doc}") - # if 'scouter_id' in doc: - # scouter = self.db.users.find_one({"_id": doc['scouter_id']}) - # logger.info(f"Associated scouter: {scouter}") - - # Log the user's info - # user_info = self.db.users.find_one({"_id": ObjectId(user_id)}) - # logger.info(f"User info: {user_info}") - - # Add match stage for filtering based on team number or user ID - if user_team_number: - # If user has a team number, show data from their team and their own data - match_stage = { - "$match": { - "$or": [ - {"scouter.teamNumber": user_team_number}, - {"scouter._id": ObjectId(user_id)} - ] - } - } - logger.info(f"Using team filter with team number: {user_team_number}") - else: - # If user has no team, only show their own data - match_stage = { - "$match": { - "scouter._id": ObjectId(user_id) - } - } - logger.info("Using individual user filter") - - pipeline = [ - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter", - } - }, - {"$unwind": "$scouter"}, - *( - match_stage, - { - "$project": { - "_id": 1, - "team_number": 1, - "drive_type": 1, - "swerve_modules": 1, - "fuel_count_bps": 1, - "shooter_type": 1, - "shooter_notes": 1, - "intake_notes": 1, - "hopper_notes": 1, - "autonomous_capabilities": 1, - "driver_experience": 1, - "notes": 1, - "created_at": 1, - "updated_at": 1, - "scouter_id": "$scouter._id", - "scouter_name": "$scouter.username", - "scouter_team": "$scouter.teamNumber", - } - }, - ), - ] - # Log the full pipeline for debugging - # logger.info(f"MongoDB pipeline: {pipeline}") - - # Execute the pipeline on the pit_scouting collection - pit_data = list(self.db.pit_scouting.aggregate(pipeline)) - logger.info(f"Retrieved {len(pit_data)} pit scouting records") - - # Log the first record if any exist (excluding sensitive info) - # if pit_data: - # sample_record = pit_data[0].copy() - # if "scouter_id" in sample_record: - # del sample_record["scouter_id"] - # logger.info(f"Sample record: {sample_record}") - - return pit_data - + return list(self.db.pit_scouting.aggregate([ + *self._visible_records(user_id, user_team_number), + {"$project": {**PIT_PROJECTION, "created_at": 1, "updated_at": 1}}, + ])) except Exception as e: logger.error(f"Error fetching pit scouting data: {str(e)}", exc_info=True) return [] diff --git a/tests/test_match13_fuel.py b/tests/test_match13_fuel.py index 1b190c0..d26a796 100644 --- a/tests/test_match13_fuel.py +++ b/tests/test_match13_fuel.py @@ -1,5 +1,6 @@ """No real API calls or MongoDB connections; writes use an in-memory fake.""" import copy +from functools import partial from datetime import datetime, timezone import json from concurrent.futures import ThreadPoolExecutor @@ -15,6 +16,7 @@ import requests from app.scout.routes import common, records +from app.scout.scouting_utils import ScoutingManager from app.scout.match13_fuel import (FUEL_FIELDS, PHASES, FuelEstimateService, apportioned, zero_scored_fuel, estimate_fuel, match_key) @@ -458,6 +460,9 @@ def setUp(self): self.user = SimpleNamespace(get_id=lambda: '6ac99468539dec0f82df2c4e', teamNumber=334, is_authenticated=True, username='tester') self.manager = MagicMock() self.manager.add_scouting_data.return_value = (True, str(ObjectId())) + # Real query builders so the polling test sees the actual access filter + for name in ('get_visible_fuel', '_visible_records'): + setattr(self.manager, name, partial(getattr(ScoutingManager, name), self.manager)) def test_zero_submissions_enroll_even_with_ferry_and_nonzero_scoring_stays_manual(self): with patch.object(records, 'current_user', self.user), patch.object(common, 'scouting_manager', self.manager): diff --git a/tests/test_scout_routes_common.py b/tests/test_scout_routes_common.py index 3105259..a681181 100644 --- a/tests/test_scout_routes_common.py +++ b/tests/test_scout_routes_common.py @@ -4,19 +4,18 @@ from bson import ObjectId -from app.scout.routes.common import actor_name, fuel_total, team_access_match +from app.scout.routes.common import actor_name, fuel_total +from app.scout.scouting_utils import access_match class CommonHelperTests(unittest.TestCase): def test_access_match_limits_to_own_records_without_team(self): user_id = str(ObjectId()) - user = SimpleNamespace(get_id=lambda: user_id, teamNumber=None) - self.assertEqual(team_access_match(user), {"$match": {"scouter._id": ObjectId(user_id)}}) + self.assertEqual(access_match(user_id, None), {"$match": {"scouter._id": ObjectId(user_id)}}) def test_access_match_adds_team_records(self): user_id = str(ObjectId()) - user = SimpleNamespace(get_id=lambda: user_id, teamNumber=334) - self.assertEqual(team_access_match(user), {"$match": {"$or": [ + self.assertEqual(access_match(user_id, 334), {"$match": {"$or": [ {"scouter._id": ObjectId(user_id)}, {"scouter.teamNumber": 334}]}}) def test_fuel_total_excludes_ferried(self): diff --git a/tests/test_scouting_pagination.py b/tests/test_scouting_pagination.py index d39c7c4..34bc445 100644 --- a/tests/test_scouting_pagination.py +++ b/tests/test_scouting_pagination.py @@ -31,7 +31,7 @@ def test_team_visibility_still_includes_team_and_own_records(self): self.manager.db.team_data.aggregate.return_value = iter([{'records': [], 'count': []}]) self.manager.get_all_scouting_data(334, self.user_id, page=1) pipeline = self.manager.db.team_data.aggregate.call_args.args[0] - self.assertEqual(pipeline[2]['$match']['$or'], [ + self.assertCountEqual(pipeline[2]['$match']['$or'], [ {'scouter.teamNumber': 334}, {'scouter._id': ObjectId(self.user_id)}]) def test_search_is_literal_and_runs_before_pagination(self): From 2d650d5b8b301315ec339bc906c8514d28da008a Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:41:25 -0400 Subject: [PATCH 05/23] Share pit scouting form parsing between add and edit Add and edit each parsed the pit form by hand and had drifted: edit used int() directly, so a blank driver_years or auto_routes raised and the edit failed, and its error log referenced `data` before it was assigned. _pit_form() now builds the shared fields with add's lenient parsing (blank or missing numbers are 0); add/edit only add their own metadata. Behaviour change: blank numeric fields on edit now save as 0 instead of failing. Co-Authored-By: Claude Opus 5.5 --- app/scout/routes/pit.py | 123 +++++++++++++++++----------------------- tests/test_pit_form.py | 55 ++++++++++++++++++ 2 files changed, 106 insertions(+), 72 deletions(-) create mode 100644 tests/test_pit_form.py diff --git a/app/scout/routes/pit.py b/app/scout/routes/pit.py index 3362fb8..a0f0a48 100644 --- a/app/scout/routes/pit.py +++ b/app/scout/routes/pit.py @@ -11,6 +11,52 @@ from .common import actor_name, scouting_bp +def _form_int(name: str) -> int: + """Integer form field; blank or missing is 0.""" + return int(request.form.get(name) or 0) + + +def _pit_form() -> dict: + """Pit scouting fields from the add/edit form.""" + form = request.form + has_auto = form.get("has_auto") == "true" + return { + "team_number": int(form["team_number"]), + + # Drive base information + "drive_type": { + "swerve": "swerve" in form.getlist("drive_type"), + "tank": "tank" in form.getlist("drive_type"), + "other": form.get("drive_type_other", "") + }, + "swerve_modules": form.get("swerve_modules", ""), + + # Mechanisms + "fuel_count_bps": _form_int("fuel_count_bps"), + "shooter_type": form.get("shooter_type", ""), + "shooter_notes": form.get("shooter_notes", ""), + "hopper_notes": form.get("hopper_notes", ""), + "intake_notes": form.get("intake_notes", ""), + + # Autonomous + "autonomous_capabilities": { + "has_auto": has_auto, + "num_routes": _form_int("auto_routes") if has_auto else 0, + "preferred_start": form.get("auto_preferred_start", "") if has_auto else "", + "notes": form.get("auto_notes", "") if has_auto else "" + }, + + # Driver Experience + "driver_experience": { + "years": _form_int("driver_years"), + "notes": form.get("driver_notes", "") + }, + + # General Notes + "notes": form.get("notes", ""), + } + + @scouting_bp.route("/scouting/pit") @login_required # @limiter.limit("50 per minute") @@ -36,47 +82,8 @@ def pit_scouting_add(): if request.method == "POST": try: current_app.logger.info(f"Scouting.add Form Details {request.form}") - # Process form data - pit_data = { - "team_number": int(request.form.get("team_number")), - "scouter_id": current_user.id, - - # Drive base information - "drive_type": { - "swerve": "swerve" in request.form.getlist("drive_type"), - "tank": "tank" in request.form.getlist("drive_type"), - "other": request.form.get("drive_type_other", "") - }, - "swerve_modules": request.form.get("swerve_modules", ""), - - # Mechanisms - "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), - "shooter_type": request.form.get("shooter_type", ""), - "shooter_notes": request.form.get("shooter_notes", ""), - "hopper_notes": request.form.get("hopper_notes", ""), - "intake_notes": request.form.get("intake_notes", ""), - - # Autonomous - "autonomous_capabilities": { - "has_auto": request.form.get("has_auto") == "true", - "num_routes": int(request.form.get("auto_routes", 0) or 0) if request.form.get("has_auto") == "true" else 0, - "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", - "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" - }, - - # Driver Experience - "driver_experience": { - "years": int(request.form.get("driver_years", 0) if not (request.form.get("driver_years") == '') else 0), - "notes": request.form.get("driver_notes", "") - }, - - # General Notes - "notes": request.form.get("notes", ""), - - # Timestamps - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc) - } + now = datetime.now(timezone.utc) + pit_data = {**_pit_form(), "scouter_id": current_user.id, "created_at": now, "updated_at": now} # Add to database if common.scouting_manager.add_pit_scouting(pit_data): @@ -110,36 +117,8 @@ def pit_scouting_edit(entry_id): if request.method == "POST": try: current_app.logger.info(f"Scouting.edit Form Details {request.form}") - data = { - "team_number": int(request.form["team_number"]), - "scouter_id": ObjectId(current_user.get_id()), - "drive_type": { - "swerve": "swerve" in request.form.getlist("drive_type"), - "tank": "tank" in request.form.getlist("drive_type"), - "other": request.form.get("drive_type_other", "") - }, - "swerve_modules": request.form.get("swerve_modules", ""), - - # Mechanisms - "fuel_count_bps": int(request.form.get("fuel_count_bps", 0) or 0), - "shooter_type": request.form.get("shooter_type", ""), - "shooter_notes": request.form.get("shooter_notes", ""), - "hopper_notes": request.form.get("hopper_notes", ""), - "intake_notes": request.form.get("intake_notes", ""), - - "autonomous_capabilities": { - "has_auto": request.form.get("has_auto") == "true", - "num_routes": int(request.form.get("auto_routes", 0)) if request.form.get("has_auto") == "true" else 0, - "preferred_start": request.form.get("auto_preferred_start", "") if request.form.get("has_auto") == "true" else "", - "notes": request.form.get("auto_notes", "") if request.form.get("has_auto") == "true" else "" - }, - "driver_experience": { - "years": int(request.form.get("driver_years", 0)), - "notes": request.form.get("driver_notes", "") - }, - "notes": request.form.get("notes", ""), - "updated_at": datetime.now(timezone.utc) - } + data = {**_pit_form(), "scouter_id": ObjectId(current_user.get_id()), + "updated_at": datetime.now(timezone.utc)} if common.scouting_manager.update_pit_scouting(entry_id, data, current_user.get_id()): current_app.logger.info(f"Successfully updated pit scouting data {data} for user {actor_name(current_user)}") @@ -149,7 +128,7 @@ def pit_scouting_edit(entry_id): current_app.logger.info(f"Failed to update pit scouting data {data} for user {actor_name(current_user)}") flash("Error updating pit scouting data", "error") except Exception as e: - current_app.logger.info(f"Error updating pit scouting data {data} for user {actor_name(current_user)} {str(e)}", exc_info=True) + current_app.logger.info(f"Error updating pit scouting entry {entry_id} for user {actor_name(current_user)} {str(e)}", exc_info=True) flash("An internal error has occurred.", "error") return render_template("scouting/pit-scouting-edit.html", entry=pit_data) diff --git a/tests/test_pit_form.py b/tests/test_pit_form.py new file mode 100644 index 0000000..4db6c64 --- /dev/null +++ b/tests/test_pit_form.py @@ -0,0 +1,55 @@ +"""Pit scouting form parsing for add and edit; managers mocked, no database.""" +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +from bson import ObjectId +from flask import Flask + +from app.scout.routes import common, pit + + +class PitFormTests(unittest.TestCase): + def setUp(self): + self.user_id = str(ObjectId()) + self.app = Flask(__name__) + self.app.config.update(LOGIN_DISABLED=True, SECRET_KEY='test-only') + self.app.add_url_rule('/scouting/pit', endpoint='scouting.pit_scouting', view_func=lambda: 'list') + self.app.add_url_rule('/add', view_func=pit.pit_scouting_add, methods=['POST']) + self.app.add_url_rule('/edit/', view_func=pit.pit_scouting_edit, methods=['POST']) + self.user = SimpleNamespace(id=self.user_id, get_id=lambda: self.user_id, is_authenticated=True, username='t') + self.manager = MagicMock() + self.manager.get_pit_scouting_by_id.return_value = {"scouter_id": ObjectId(self.user_id)} + + def post(self, url, form): + with patch.object(pit, 'current_user', self.user), patch.object(common, 'scouting_manager', self.manager), \ + patch.object(pit, 'render_template', return_value='form'): + return self.app.test_client().post(url, data=form) + + def test_blank_numbers_save_as_zero_on_add_and_edit(self): + form = {'team_number': '334', 'driver_years': '', 'fuel_count_bps': '', 'has_auto': 'true', 'auto_routes': ''} + self.post('/add', form) + self.post(f'/edit/{ObjectId()}', form) + added = self.manager.add_pit_scouting.call_args.args[0] + edited = self.manager.update_pit_scouting.call_args.args[1] + for data in (added, edited): + self.assertEqual(data['driver_experience']['years'], 0) + self.assertEqual(data['fuel_count_bps'], 0) + self.assertEqual(data['autonomous_capabilities']['num_routes'], 0) + self.assertEqual(data['team_number'], 334) + self.assertIn('created_at', added) + self.assertNotIn('created_at', edited) + self.assertEqual(edited['scouter_id'], ObjectId(self.user_id)) + + def test_auto_details_ignored_without_auto(self): + self.post('/add', {'team_number': '1', 'auto_routes': '3', 'auto_notes': 'x'}) + auto = self.manager.add_pit_scouting.call_args.args[0]['autonomous_capabilities'] + self.assertEqual(auto, {'has_auto': False, 'num_routes': 0, 'preferred_start': '', 'notes': ''}) + + def test_missing_team_number_on_edit_is_handled(self): + self.assertEqual(self.post(f'/edit/{ObjectId()}', {}).status_code, 200) + self.manager.update_pit_scouting.assert_not_called() + + +if __name__ == '__main__': + unittest.main() From 30f9a2d746f87c57566904069112e306ae9c818a Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:41:25 -0400 Subject: [PATCH 06/23] Unify model loaders and drop dead model code - _with_object_ids replaces the per-class ObjectId coercion; User, Team and AssignmentSubscription.create_from_db become one-line classmethods - User no longer overrides UserMixin's is_authenticated/is_active/ is_anonymous properties with methods (a bound method is always truthy, so is_anonymous was "true" for every user); callers only ever tested truthiness of the first two - remove unused PitScouting class, TeamData.formatted_date (read an attribute that was never set), User.update_team_number, and Team.add_user/remove_user Co-Authored-By: Claude Opus 5.5 --- app/models.py | 131 +++++++------------------------------------------- 1 file changed, 17 insertions(+), 114 deletions(-) diff --git a/app/models.py b/app/models.py index 1cb5872..5eb7435 100644 --- a/app/models.py +++ b/app/models.py @@ -6,6 +6,14 @@ from werkzeug.security import check_password_hash +def _with_object_ids(data: Dict, *fields: str) -> Dict: + """Coerce present, truthy id fields of a db document to ObjectId (in place).""" + for field in fields: + if data.get(field) and not isinstance(data[field], ObjectId): + data[field] = ObjectId(data[field]) + return data + + class User(UserMixin): def __init__(self, data): self._id: ObjectId = data.get('_id') @@ -25,27 +33,12 @@ def id(self): def get_id(self) -> str: return str(self._id) - def is_authenticated(self) -> bool: - return True - - def is_active(self) -> bool: - return True - - def is_anonymous(self) -> bool: - return False - def check_password(self, password: str) -> bool: return check_password_hash(self.password_hash, password) - @staticmethod - def create_from_db(user_data): - """Creates a User instance from database data""" - if not user_data: - return None - # Ensure _id is ObjectId - if "_id" in user_data and not isinstance(user_data["_id"], ObjectId): - user_data["_id"] = ObjectId(user_data["_id"]) - return User(user_data) + @classmethod + def create_from_db(cls, data): + return cls(_with_object_ids(data, "_id")) if data else None def to_dict(self) -> Dict: return { @@ -60,12 +53,6 @@ def to_dict(self) -> Dict: "profile_picture_id": str(self.profile_picture_id) if self.profile_picture_id else None, } - def update_team_number(self, team_number: int) -> "User": - """Update the user's team number""" - self.teamNumber = team_number - return self - - class TeamData: def __init__(self, data): self.id: str = str(data.get('_id')) @@ -116,71 +103,11 @@ def __init__(self, data): # Robot Disabled Status self.robot_disabled: str = data.get('robot_disabled', 'None') # 'None', 'Partially', 'Full' - @classmethod def create_from_db(cls, data) -> "TeamData": return cls(data) - @property - def formatted_date(self) -> str: - """Returns formatted creation date""" - if self.created_at: - return self.created_at.strftime("%Y-%m-%d %H:%M:%S") - return "N/A" - - - -class PitScouting: - def __init__(self, data: Dict) -> None: - self._id = data.get("_id") - self.team_number = data.get("team_number") - self.scouter_id = data.get("scouter_id") - - # Drive base information - self.drive_type: Dict[str, bool or str] = data.get("drive_type", { - "swerve": False, - "tank": False, - "other": "" - }) - self.swerve_modules: str = data.get("swerve_modules", "") - - # Mechanism details - self.fuel_count_bps: int = data.get("fuel_count_bps", 0) - self.shooter_type: str = data.get("shooter_type", "") - - self.intake_type: str = data.get("intake_type_bps", "") - self.hopper_type: str = data.get("hopper_type", "") - - # Autonomous - self.autonomous_capabilities: Dict = data.get("autonomous_capabilities", { - "has_auto": False, - "num_routes": 0, - "preferred_start": "", - "notes": "" - }) - - # Driver Experience - self.driver_experience: Dict = data.get("driver_experience", { - "years": 0, - "notes": "" - }) - - # Analysis - self.notes: str = data.get("notes", "") - - # Metadata - self.created_at: Optional[datetime] = data.get("created_at") - self.updated_at: Optional[datetime] = data.get("updated_at") - - @staticmethod - def create_from_db(data: Dict): - """Create a PitScouting instance from database data""" - if not data: - return None - if "_id" in data and not isinstance(data["_id"], ObjectId): - data["_id"] = ObjectId(data["_id"]) - return PitScouting(data) class Team: def __init__(self, data: Dict) -> None: @@ -207,29 +134,10 @@ def is_owner(self, user_id: str) -> bool: def id(self) -> Optional[str]: return str(self._id) if self._id else None - @staticmethod - def create_from_db(data: Dict): - if not data: - return None - # Convert string ID to ObjectId if necessary - if "_id" in data and not isinstance(data["_id"], ObjectId): - data["_id"] = ObjectId(data["_id"]) - if "logo_id" in data and not isinstance(data["logo_id"], ObjectId) and data["logo_id"]: - data["logo_id"] = ObjectId(data["logo_id"]) - return Team(data) - - def add_user(self, user: UserMixin) -> None: - # Assuming user is an instance of User (or any UserMixin subclass) - if isinstance(user, UserMixin): - self.users.append(user.get_id()) # Store the User ID - else: - raise ValueError("Expected a UserMixin instance") + @classmethod + def create_from_db(cls, data: Dict): + return cls(_with_object_ids(data, "_id", "logo_id")) if data else None - def remove_user(self, user: UserMixin) -> None: - if isinstance(user, UserMixin): - self.users = [uid for uid in self.users if uid != user.get_id()] - else: - raise ValueError("Expected a UserMixin instance") class Assignment: def __init__(self, id: str, title: str, description: str, team_number: int, @@ -310,14 +218,9 @@ def __init__(self, data: Dict) -> None: def id(self): return str(self._id) - @staticmethod - def create_from_db(data: Dict): - """Create an AssignmentSubscription instance from database data""" - if not data: - return None - if "_id" in data and not isinstance(data["_id"], ObjectId): - data["_id"] = ObjectId(data["_id"]) - return AssignmentSubscription(data) + @classmethod + def create_from_db(cls, data: Dict): + return cls(_with_object_ids(data, "_id")) if data else None def mark_as_sent(self) -> None: """Mark the notification as sent""" From 90a6c1c885cfb9d6f63d972ee5baf551a6c8ed7b Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:46:27 -0400 Subject: [PATCH 07/23] Remove commented-out rate limits and dead test notification code Delete 25 "# @limiter.limit(...)" lines rather than enable them: the limiter keys on client IP, and scouters at an event share the venue's address, so per-IP limits sized for one person would throttle a whole team. Also remove the commented-out /notifications/test route and NotificationManager.test_notification. Co-Authored-By: Claude Opus 5.5 --- app/auth/routes.py | 1 - app/notifications/notification_manager.py | 44 ----------------------- app/notifications/routes.py | 22 ------------ app/scout/routes/events.py | 3 -- app/scout/routes/leaderboards.py | 2 -- app/scout/routes/lighthouse.py | 5 --- app/scout/routes/pit.py | 3 -- app/scout/routes/records.py | 5 --- app/team/routes.py | 5 --- 9 files changed, 90 deletions(-) diff --git a/app/auth/routes.py b/app/auth/routes.py index 8234510..3e6a868 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -23,7 +23,6 @@ def on_blueprint_init(state): @auth_bp.route("/login", methods=["GET", "POST"]) -# @limiter.limit("8 per minute") @handle_route_errors def login(): if current_user.is_authenticated: diff --git a/app/notifications/notification_manager.py b/app/notifications/notification_manager.py index 195d7b9..d7bb046 100644 --- a/app/notifications/notification_manager.py +++ b/app/notifications/notification_manager.py @@ -421,50 +421,6 @@ def delete_subscription(self, user_id: str, team_number: int = None, logger.error(f"Error deleting subscription: {str(e)}") return False, "An internal error has occurred." - # @with_mongodb_retry() - # def test_notification(self, user_id: str) -> Tuple[bool, str]: - # """Send a test notification to a user - - # Args: - # user_id: The user ID - - # Returns: - # Tuple[bool, str]: Success status and message - # """ - # - - # try: - # # Get the user's subscriptions - # subscriptions = self.db.assignment_subscriptions.find({ - # "user_id": user_id, - # "subscription_json": {"$exists": True, "$ne": {}} - # }) - - # if not subscriptions: - # return False, "No subscription found for this user" - - # for sub_data in subscriptions: - # subscription = AssignmentSubscription.create_from_db(sub_data) - - # # Override notification data for test - # subscription.title = "Test Notification" - # subscription.body = "This is a test notification from the Scouting App" - # subscription.url = "/team/manage" - # subscription.data = { - # "test": True, - # "type": "test_notification", - # "timestamp": datetime.now().isoformat() - # } - - # if _ := self._send_push_notification(subscription): - # return True, "Test notification sent successfully" - - # return False, "Failed to send test notification" - - # except Exception as e: - # logger.error(f"Error sending test notification: {str(e)}") - # return False, "An internal error has occurred." - def send_instant_assignment_notification(self, assignment_data: Dict, team_number: int) -> None: """Send instant notifications to users when they are assigned to a new assignment diff --git a/app/notifications/routes.py b/app/notifications/routes.py index 0417037..b114427 100644 --- a/app/notifications/routes.py +++ b/app/notifications/routes.py @@ -123,25 +123,3 @@ def delete_subscription(): "success": False, "message": "An internal error has occurred." }), 500 - -# @notifications_bp.route("/test", methods=["POST"]) -# @login_required -# @limiter.limit("3 per minute") -# def test_notification(): -# """Send a test notification to the current user""" -# try: -# success, message = notification_manager.test_notification( -# user_id=current_user.get_id() -# ) - -# return jsonify({ -# "success": success, -# "message": message -# }), 200 if success else 400 - -# except Exception as e: -# current_app.logger.error(f"Error sending test notification: {str(e)}") -# return jsonify({ -# "success": False, -# "message": "An internal error has occurred." -# }), 500 diff --git a/app/scout/routes/events.py b/app/scout/routes/events.py index 00e976a..3d41d10 100644 --- a/app/scout/routes/events.py +++ b/app/scout/routes/events.py @@ -14,7 +14,6 @@ @scouting_bp.route("/api/tba/events") @login_required -# @limiter.limit("30 per minute") def get_tba_events(): try: year = request.args.get('year', default=datetime.now().year, type=int) @@ -37,7 +36,6 @@ def get_tba_events(): @scouting_bp.route("/api/tba/matches/") @login_required -# @limiter.limit("30 per minute") def get_tba_matches(event_key): try: tba = TBAInterface() @@ -53,7 +51,6 @@ def get_tba_matches(event_key): @scouting_bp.route("/scouting/live-match-status", methods=["GET"]) @login_required -# @limiter.limit("30 per minute") def live_match_status(): """Route for the live team schedule modal""" context = { diff --git a/app/scout/routes/leaderboards.py b/app/scout/routes/leaderboards.py index 0250db7..e72c953 100644 --- a/app/scout/routes/leaderboards.py +++ b/app/scout/routes/leaderboards.py @@ -15,7 +15,6 @@ @scouting_bp.route("/leaderboard") -# @limiter.limit("30 per minute") def leaderboard(): try: sort_type = request.args.get('sort', 'fuel') @@ -50,7 +49,6 @@ def leaderboard(): @scouting_bp.route("/scouter-leaderboard") -# @limiter.limit("30 per minute") @login_required def scouter_leaderboard(): try: diff --git a/app/scout/routes/lighthouse.py b/app/scout/routes/lighthouse.py index 38e68af..46f488b 100644 --- a/app/scout/routes/lighthouse.py +++ b/app/scout/routes/lighthouse.py @@ -14,7 +14,6 @@ @scouting_bp.route("/lighthouse") -# @limiter.limit("30 per minute") @login_required def lighthouse(): current_app.logger.info(f"Successfully fetched lighthouse for user {actor_name(current_user)}") @@ -22,7 +21,6 @@ def lighthouse(): @scouting_bp.route("/lighthouse/auton") -# @limiter.limit("30 per minute") @login_required def auton(): current_app.logger.info(f"Successfully fetched lighthouse/auton for user {actor_name(current_user)}") @@ -30,7 +28,6 @@ def auton(): @scouting_bp.route("/api/compare") -# @limiter.limit("30 per minute") @login_required def compare_teams(): try: @@ -99,7 +96,6 @@ def compare_teams(): @scouting_bp.route("/api/search") @login_required -# @limiter.limit("30 per minute") def search_teams(): query = request.args.get("q", "").strip() if not query: @@ -153,7 +149,6 @@ def search_teams(): @scouting_bp.route("/api/team_paths") @login_required -# @limiter.limit("30 per minute") def get_team_paths(): team_number = request.args.get('team') diff --git a/app/scout/routes/pit.py b/app/scout/routes/pit.py index a0f0a48..61bc949 100644 --- a/app/scout/routes/pit.py +++ b/app/scout/routes/pit.py @@ -59,7 +59,6 @@ def _pit_form() -> dict: @scouting_bp.route("/scouting/pit") @login_required -# @limiter.limit("50 per minute") def pit_scouting(): try: # Update to use filtered pit scouting data @@ -77,7 +76,6 @@ def pit_scouting(): @scouting_bp.route("/scouting/pit/add", methods=["GET", "POST"]) @login_required -# @limiter.limit("15 per minute") def pit_scouting_add(): if request.method == "POST": try: @@ -103,7 +101,6 @@ def pit_scouting_add(): @scouting_bp.route("/scouting/pit/edit/", methods=["GET", "POST"]) @login_required -# @limiter.limit("10 per minute") def pit_scouting_edit(entry_id): pit_data = common.scouting_manager.get_pit_scouting_by_id(entry_id) if not pit_data: diff --git a/app/scout/routes/records.py b/app/scout/routes/records.py index e2ee49a..c0bf280 100644 --- a/app/scout/routes/records.py +++ b/app/scout/routes/records.py @@ -26,7 +26,6 @@ def _user_team(): @scouting_bp.route("/scouting/add", methods=["GET", "POST"]) @login_required -# @limiter.limit("15 per minute") @handle_route_errors def add(): if request.method != "POST": @@ -76,7 +75,6 @@ def add(): @scouting_bp.route("/scouting/list") @scouting_bp.route("/scouting") -# @limiter.limit("30 per minute") @login_required def home(): try: @@ -127,7 +125,6 @@ def fuel_estimates(): @scouting_bp.route("/scouting/edit/", methods=["GET", "POST"]) -# @limiter.limit("15 per minute") @login_required def edit(id): try: @@ -183,7 +180,6 @@ def edit(id): @scouting_bp.route("/scouting/delete/") -# @limiter.limit("10 per minute") @login_required def delete(id): try: @@ -245,7 +241,6 @@ def _match_team_rows(teams: list[dict], statuses: dict) -> list[dict]: @scouting_bp.route("/scouting/matches") -# @limiter.limit("30 per minute") @login_required def matches(): try: diff --git a/app/team/routes.py b/app/team/routes.py index bb32be9..3510c5c 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -202,7 +202,6 @@ def remove_admin(team_number): @team_bp.route("//assignments", methods=["POST"]) @login_required -# @limiter.limit("10 per minute") def create_assignment(team_number): """Create a new assignment""" @@ -230,7 +229,6 @@ def create_assignment(team_number): @team_bp.route("/assignments//status", methods=["PUT"]) @login_required -# @limiter.limit("5 per minute") def update_assignment_status(assignment_id): """Update assignment status""" @@ -249,7 +247,6 @@ def update_assignment_status(assignment_id): @team_bp.route("/assignments//update", methods=["PUT"]) @login_required -# @limiter.limit("15 per minute") def update_assignment(assignment_id): """Update assignment""" @@ -263,7 +260,6 @@ def update_assignment(assignment_id): @team_bp.route("/assignments//delete", methods=["DELETE"]) @login_required -# @limiter.limit("10 per minute") def delete_assignment(assignment_id): """Delete assignment""" @@ -278,7 +274,6 @@ def delete_assignment(assignment_id): @team_bp.route("/manage/", methods=["GET", "POST"]) @team_bp.route("/", methods=["GET", "POST"]) @login_required -# @limiter.limit("30 per minute") def manage(team_number=None): """Manage team""" From 753330edec4df950761f0ca2e67dc0d97f87eaee Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:46:27 -0400 Subject: [PATCH 08/23] Move inline page scripts to static files and share scouting form canvas - base.html's 278-line service worker/offline script moves to static/js/base.js; settings.html's 355-line script moves to static/js/auth/settings.js. The two template values they used are passed as data- attributes read via document.currentScript (which also stops HTML-escaping the username inside a JS string) - add.js and edit.js shared ~430 identical lines of form, canvas, toolbar and keyboard-shortcut setup; that is now initScoutFormCanvas() in static/js/scout/form-canvas.js, with the two real differences as options (edit loads the saved path; add's Home button also resizes). Inlining the function reproduces both original files line for line. - remove pit-scouting/edit.js, which no template loads - service worker precaches base.js and form-canvas.js (the add page works offline); cache names bumped to v11 so clients refetch - offline.html keeps its inline script so it stays self-contained Co-Authored-By: Claude Opus 5.5 --- app/static/js/auth/settings.js | 353 +++++++++++++++++++ app/static/js/base.js | 277 +++++++++++++++ app/static/js/pit-scouting/edit.js | 29 -- app/static/js/scout/add.js | 425 +---------------------- app/static/js/scout/edit.js | 522 +++-------------------------- app/static/js/scout/form-canvas.js | 430 ++++++++++++++++++++++++ app/static/js/service-worker.js | 12 +- app/templates/auth/settings.html | 356 +------------------- app/templates/base.html | 280 +--------------- app/templates/scouting/add.html | 1 + app/templates/scouting/edit.html | 1 + tests/test_browser_resilience.js | 6 +- 12 files changed, 1129 insertions(+), 1563 deletions(-) create mode 100644 app/static/js/auth/settings.js create mode 100644 app/static/js/base.js delete mode 100644 app/static/js/pit-scouting/edit.js create mode 100644 app/static/js/scout/form-canvas.js diff --git a/app/static/js/auth/settings.js b/app/static/js/auth/settings.js new file mode 100644 index 0000000..5d13f1b --- /dev/null +++ b/app/static/js/auth/settings.js @@ -0,0 +1,353 @@ +// Toast notification function +function showNotification(message, type = "info") { + const notificationArea = document.getElementById("notification-area"); + if (!notificationArea) return; + + const colors = { + success: { + bg: "bg-green-50", + text: "text-green-800", + border: "border-green-300", + icon: "text-green-600", + }, + error: { + bg: "bg-red-50", + text: "text-red-800", + border: "border-red-300", + icon: "text-red-600", + }, + warning: { + bg: "bg-yellow-50", + text: "text-yellow-800", + border: "border-yellow-300", + icon: "text-yellow-600", + }, + info: { + bg: "bg-blue-50 dark:bg-blue-900/20", + text: "text-blue-800", + border: "border-blue-300", + icon: "text-blue-600", + }, + }; + + const icons = { + success: + '', + error: + '', + warning: + '', + info: '', + }; + + const color = colors[type] || colors.info; + const icon = icons[type] || icons.info; + + const notification = document.createElement("div"); + notification.className = `flex items-center p-4 rounded-lg shadow-lg dark:shadow-none ${color.bg} ${color.text} border-2 ${color.border} animate-slide-in`; + notification.style.cssText = + "animation: slideIn 0.3s ease-out; min-width: 300px;"; + + notification.innerHTML = ` + + ${icon} + +

${message}

+ + `; + + notificationArea.appendChild(notification); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + notification.style.opacity = "0"; + notification.style.transition = "opacity 300ms"; + setTimeout(() => notification.remove(), 300); + }, 5000); +} + +// Add animation keyframe +const style = document.createElement("style"); +style.textContent = ` + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } +`; +document.head.appendChild(style); + +// Handle form submission +document.querySelector("form").addEventListener("submit", function (e) { + e.preventDefault(); // Prevent default form submission + + // Create FormData object from the form + const formData = new FormData(this); + + // Submit the form using fetch + fetch(window.location.href, { + method: "POST", + body: formData, + // Don't set Content-Type header when using FormData - it will set it automatically with boundary + }) + .then((response) => { + if (response.redirected) { + // If the server redirects, follow that redirect + window.location.href = response.url; + } else { + // If not redirected but successful, refresh the page to show updated info + window.location.reload(); + } + }) + .catch((error) => { + console.error("Error submitting form:", error); + showNotification( + "An error occurred while saving your changes. Please try again.", + "error", + ); + }); +}); + +document + .getElementById("profile_picture") + .addEventListener("change", function (e) { + const file = e.target.files[0]; + + // Check file size (6MB = 6 * 1024 * 1024 bytes) + const maxSize = 6 * 1024 * 1024; + if (file && file.size > maxSize) { + showNotification("File size must be less than 6MB", "error"); + this.value = ""; // Clear the file input + document.getElementById("upload-info").textContent = ""; + return; + } + + if (file) { + // Update file name display + document.getElementById("upload-info").textContent = file.name; + + // Show immediate preview first before compression starts + const reader = new FileReader(); + reader.onload = function (e) { + document.querySelector(".profile-preview").src = e.target.result; + }; + reader.readAsDataURL(file); + + // Then use Compressor.js to compress the image in background + try { + new Compressor(file, { + quality: 0.8, // 0.8 means 80% quality + maxWidth: 800, + maxHeight: 800, + mimeType: "image/jpeg", + success(result) { + // Replace the file in the input with the compressed one + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File([result], file.name, { type: "image/jpeg" }), + ); + document.getElementById("profile_picture").files = + dataTransfer.files; + console.log("Image compressed successfully"); + }, + error(err) { + console.error("Image compression failed:", err); + // No need to update preview again since we already did it above + }, + }); + } catch (err) { + console.error("Error initializing compressor:", err); + // The preview is already shown above, so no need for fallback here + } + } + }); + +let usernameCheckTimeout; +const currentUsername = document.currentScript.dataset.currentUsername; + +function checkUsername(username) { + // Clear any existing timeout + clearTimeout(usernameCheckTimeout); + + const statusDiv = document.getElementById("username-status"); + + // Don't check if username hasn't changed + if (username === currentUsername) { + statusDiv.innerHTML = ""; + return; + } + + // Check username length and characters + if (username.length < 3) { + statusDiv.innerHTML = + 'Username must be at least 3 characters long'; + return; + } + + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + statusDiv.innerHTML = + 'Username can only contain letters, numbers, and underscores'; + return; + } + + // Set a timeout to avoid too many requests + usernameCheckTimeout = setTimeout(() => { + statusDiv.innerHTML = + 'Checking availability...'; + + fetch("/auth/check_username", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ username: username }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.available) { + statusDiv.innerHTML = + '✓ Username is available'; + } else { + statusDiv.innerHTML = + '✗ Username is already taken'; + } + }) + .catch((error) => { + console.error("Error:", error); + statusDiv.innerHTML = + 'Error checking username availability'; + }); + }, 500); // Wait 500ms after user stops typing before checking +} + +function changePassword() { + const currentPassword = document.getElementById("current_password").value; + const newPassword = document.getElementById("new_password").value; + const confirmPassword = document.getElementById( + "confirm_new_password", + ).value; + + // Client-side validation + if (!currentPassword || !newPassword || !confirmPassword) { + showNotification("All password fields are required", "warning"); + return; + } + + if (newPassword !== confirmPassword) { + showNotification("New passwords do not match", "error"); + return; + } + + if (newPassword.length < 8) { + showNotification( + "New password must be at least 8 characters long", + "error", + ); + return; + } + + if (newPassword === currentPassword) { + showNotification( + "New password must be different from current password", + "error", + ); + return; + } + + // Send request + fetch("/auth/change_password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + confirm_password: confirmPassword, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + showNotification( + data.message || "Password changed successfully", + "success", + ); + // Redirect to login if logout is required + if (data.redirect) { + setTimeout(() => { + window.location.href = data.redirect; + }, 1500); + } else { + // Clear the password fields + document.getElementById("current_password").value = ""; + document.getElementById("new_password").value = ""; + document.getElementById("confirm_new_password").value = ""; + } + } else { + showNotification( + data.message || "Failed to change password", + "error", + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showNotification( + "An error occurred while changing your password", + "error", + ); + }); +} + +function confirmDeleteAccount() { + if ( + confirm( + "Are you sure you want to delete your account? This action cannot be undone.", + ) + ) { + fetch("/auth/delete_account", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + showNotification( + "Account deleted successfully. Redirecting...", + "success", + ); + setTimeout(() => { + window.location.href = data.redirect; + }, 1500); + } else { + showNotification( + data.message || "Failed to delete account", + "error", + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showNotification( + "An error occurred while deleting your account", + "error", + ); + }); + } +} diff --git a/app/static/js/base.js b/app/static/js/base.js new file mode 100644 index 0000000..c9acfec --- /dev/null +++ b/app/static/js/base.js @@ -0,0 +1,277 @@ +// Check offline status immediately when script runs (before any events) +(function checkOfflineStatus() { + if (!navigator.onLine || localStorage.getItem('offlineMode') === 'true') { + document.body.classList.add('offline'); + // We'll call showOfflineIndicators as soon as DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', showOfflineIndicatorsImmediately); + } else { + showOfflineIndicatorsImmediately(); + } + } +})(); + +function showOfflineIndicatorsImmediately() { + const navOfflineIndicator = document.getElementById('nav-offline-indicator'); + if (navOfflineIndicator) { + navOfflineIndicator.classList.remove('hidden'); + } + + const mobileOfflineIndicator = document.getElementById('mobile-offline-indicator'); + if (mobileOfflineIndicator) { + mobileOfflineIndicator.classList.remove('hidden'); + } + + // Store offline state in localStorage to persist across page changes + localStorage.setItem('offlineMode', 'true'); +} + +if ('serviceWorker' in navigator) { + // Track the current version of content + const APP_VERSION = document.currentScript.dataset.appVersion; + + // Store the current version in localStorage + if (!localStorage.getItem('appVersion') || + localStorage.getItem('appVersion') !== APP_VERSION) { + // Version changed, clear caches + if ('caches' in window) { + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + return caches.delete(cacheName); + }) + ); + }); + } + localStorage.setItem('appVersion', APP_VERSION); + } + + let reloadForUpdate = false; + window.addEventListener('load', () => { + const registerServiceWorker = async () => { + try { + const registration = await navigator.serviceWorker.register('/service-worker.js', { + scope: '/', + updateViaCache: 'none' // Don't use cached service worker + }); + + console.log('ServiceWorker registration successful with scope: ', registration.scope); + + // Check if there's an updated service worker waiting + if (registration.waiting) { + console.log('New service worker is waiting to activate'); + notifyUserOfUpdate(registration.waiting); + } + + // Listen for new service worker installation + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + console.log('Service Worker update found!'); + + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + console.log('New content is available; please refresh.'); + notifyUserOfUpdate(newWorker); + } + }); + }); + + // Force update check every 5 minutes if the tab remains open + setInterval(() => { + registration.update(); + console.log('Checking for service worker updates...'); + }, 5 * 60 * 1000); + + // Check for updates immediately + setTimeout(() => { + registration.update(); + }, 3000); + + // Listen for controller change (new service worker took over) + navigator.serviceWorker.addEventListener('controllerchange', () => { + // Background updates must not discard an unfinished scouting form. + if (reloadForUpdate) window.location.reload(); + }); + + // Listen for messages from the service worker + navigator.serviceWorker.addEventListener('message', event => { + if (event.data && event.data.type === 'OFFLINE_STATUS') { + if (event.data.isOffline) { + document.body.classList.add('offline'); + showOfflineIndicators(); + } else { + document.body.classList.remove('offline'); + hideOfflineIndicators(); + } + } + }); + + } catch (error) { + console.error('ServiceWorker registration failed: ', error); + } + }; + + registerServiceWorker(); + }); + + // Function to notify the user about an update + function notifyUserOfUpdate(worker) { + if (document.getElementById('update-banner')) return; + const updateBanner = document.createElement('div'); + updateBanner.id = 'update-banner'; + updateBanner.classList.add('fixed', 'bottom-0', 'left-0', 'right-0', 'bg-blue-500', 'text-white', 'p-4', 'flex', 'justify-between', 'items-center', 'z-50'); + updateBanner.innerHTML = ` +
+ Update Available! Refresh to see the latest version. +
+
+ + +
+ `; + + document.body.appendChild(updateBanner); + + document.getElementById('update-now').addEventListener('click', () => { + reloadForUpdate = true; + if (worker.state === 'activated') window.location.reload(); + else worker.postMessage({ action: 'skipWaiting' }); + updateBanner.remove(); + }); + + document.getElementById('update-later').addEventListener('click', () => { + updateBanner.remove(); + }); + } + + // Function to show offline indicators + function showOfflineIndicators() { + // Show offline indicators in top navigation + const navOfflineIndicator = document.getElementById('nav-offline-indicator'); + if (navOfflineIndicator) { + navOfflineIndicator.classList.remove('hidden'); + } + + // Show mobile offline indicator + const mobileOfflineIndicator = document.getElementById('mobile-offline-indicator'); + if (mobileOfflineIndicator) { + mobileOfflineIndicator.classList.remove('hidden'); + } + + // Store offline state in localStorage to persist across page changes + localStorage.setItem('offlineMode', 'true'); + } + + // Function to hide offline indicators + function hideOfflineIndicators() { + // Hide offline indicators in top navigation + const navOfflineIndicator = document.getElementById('nav-offline-indicator'); + if (navOfflineIndicator) { + navOfflineIndicator.classList.add('hidden'); + } + + // Hide mobile offline indicator + const mobileOfflineIndicator = document.getElementById('mobile-offline-indicator'); + if (mobileOfflineIndicator) { + mobileOfflineIndicator.classList.add('hidden'); + } + + // Remove offline state from localStorage + localStorage.removeItem('offlineMode'); + } + + // Add offline/online detection + window.addEventListener('online', () => { + document.body.classList.remove('offline'); + hideOfflineIndicators(); + + // Sync any pending requests and check for updates + navigator.serviceWorker.ready.then(registration => { + registration.update(); + }); + }); + + window.addEventListener('offline', () => { + document.body.classList.add('offline'); + showOfflineIndicators(); + }); + + // The following check runs on every page load, including refreshes and navigation + // It's redundant with the IIFE at the top but provides a fallback + document.addEventListener('DOMContentLoaded', () => { + // Double-check offline status from localStorage and network state + if (localStorage.getItem('offlineMode') === 'true' || !navigator.onLine) { + document.body.classList.add('offline'); + showOfflineIndicators(); + } + + // Also double-check online status to handle case where connection was restored + // but localStorage wasn't properly cleared + if (navigator.onLine && localStorage.getItem('offlineMode') === 'true') { + // Verify online status by making a tiny request + fetch('/static/logo.png?checkOnline=' + Date.now(), { + method: 'HEAD', + cache: 'no-store' + }) + .then(() => { + // If fetch succeeds, we're definitely online + hideOfflineIndicators(); + }) + .catch(() => { + // If fetch fails, we're probably still offline + showOfflineIndicators(); + }); + } + }); + + // Add offline support to fetch requests + const originalFetch = window.fetch.bind(window); + let onlineProbe = null; + window.fetch = function(input, init) { + return originalFetch(input, init) + .then(response => { + // If a successful response, we must be online + if (response.ok && !onlineProbe && + localStorage.getItem('offlineMode') === 'true') { + onlineProbe = originalFetch('/static/logo.png?checkOnline=' + Date.now(), { + method: 'HEAD', cache: 'no-store' + }).then(probe => { + if (probe.ok) { + document.body.classList.remove('offline'); + hideOfflineIndicators(); + } + }).catch(() => {}).finally(() => { onlineProbe = null; }); + } + return response; + }) + .catch(error => { + if (error.name === 'AbortError') throw error; + console.error('Fetch error:', error); + + if (!navigator.onLine) { + // Ensure the offline indicator is shown + document.body.classList.add('offline'); + showOfflineIndicators(); + + // For JSON requests, return a valid offline response + const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); + if (headers.get('Accept')?.includes('application/json') || + headers.get('Content-Type')?.includes('application/json')) { + return new Response(JSON.stringify({ + error: 'offline', + message: 'You are offline. Please retry this action when you reconnect.' + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + throw error; + }); + }; +} diff --git a/app/static/js/pit-scouting/edit.js b/app/static/js/pit-scouting/edit.js deleted file mode 100644 index 4e4ab66..0000000 --- a/app/static/js/pit-scouting/edit.js +++ /dev/null @@ -1,29 +0,0 @@ -function toggleAutoFields(enabled) { - const fields = ['auto_routes', 'auto_preferred_start', 'auto_notes']; - fields.forEach(field => { - const element = document.querySelector(`[name="${field}"]`); - if (element) { - element.disabled = !enabled; - if (!enabled) { - element.value = ''; - } - } - }); -} - -function toggleScoringFields(type, enabled) { - const notesField = document.querySelector(`[name="${type}_scoring_notes"]`); - if (notesField) { - notesField.disabled = !enabled; - if (!enabled) { - notesField.value = ''; - } - } -} - -// Add event listeners -document.querySelectorAll('[name="has_auto"]').forEach(radio => { - radio.addEventListener('change', (e) => { - toggleAutoFields(e.target.value === 'true'); - }); -}); diff --git a/app/static/js/scout/add.js b/app/static/js/scout/add.js index 6661089..01973a7 100644 --- a/app/static/js/scout/add.js +++ b/app/static/js/scout/add.js @@ -40,428 +40,9 @@ const updateMatchResult = () => { }; document.addEventListener("DOMContentLoaded", function () { - // Event code input handling - const eventCodeInput = document.querySelector('input[name="event_code"]'); - if (eventCodeInput) { - eventCodeInput.addEventListener("input", function (e) { - this.value = this.value.toUpperCase(); - }); - } - - // Initialize CanvasField helper - const CanvasField = new Canvas({ - canvas: document.getElementById("autoPath"), - container: document.getElementById("autoPathContainer"), - externalUpdateUIControls: updateUIControls, - showStatus: (message) => { - const flashContainer = document.querySelector(".container"); - if (!flashContainer) { - return; - } - - const messageDiv = document.createElement("div"); - messageDiv.className = - "fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up"; - - const innerDiv = document.createElement("div"); - innerDiv.className = - "flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200"; - - const icon = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - icon.setAttribute("class", "w-6 h-6 mr-3 flex-shrink-0"); - icon.setAttribute("fill", "currentColor"); - icon.setAttribute("viewBox", "0 0 20 20"); - - const path = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - path.setAttribute("fill-rule", "evenodd"); - path.setAttribute( - "d", - "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - ); - path.setAttribute("clip-rule", "evenodd"); - - icon.appendChild(path); - - const text = document.createElement("p"); - text.className = "text-base font-medium"; - text.textContent = message; - - const closeButton = document.createElement("button"); - closeButton.className = - "ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100"; - closeButton.onclick = () => messageDiv.remove(); - - const closeIcon = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - closeIcon.setAttribute("class", "w-5 h-5"); - closeIcon.setAttribute("fill", "currentColor"); - closeIcon.setAttribute("viewBox", "0 0 20 20"); - - const closePath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - closePath.setAttribute("fill-rule", "evenodd"); - closePath.setAttribute( - "d", - "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", - ); - closePath.setAttribute("clip-rule", "evenodd"); - - closeIcon.appendChild(closePath); - closeButton.appendChild(closeIcon); - - innerDiv.appendChild(icon); - innerDiv.appendChild(text); - innerDiv.appendChild(closeButton); - messageDiv.appendChild(innerDiv); - - flashContainer.appendChild(messageDiv); - - setTimeout(() => { - if (messageDiv.parentNode === flashContainer) { - messageDiv.remove(); - } - }, 3000); - }, - initialColor: "#2563eb", - initialThickness: 3, - maxPanDistance: 1000, - backgroundImage: "/static/images/field-2026.png", - readonly: false, - }); - - // Verify background image loading - const testImage = new Image(); - testImage.onload = () => { - console.log("Background image loaded successfully"); - }; - testImage.onerror = () => { - console.error("Failed to load background image"); - CanvasField.showStatus("Error loading field image"); - }; - testImage.src = "/static/images/field-2026.png"; - - // Prevent page scrolling when using mouse wheel on canvas - const canvas = document.getElementById("autoPath"); - canvas.addEventListener( - "wheel", - (e) => { - if (e.target === canvas && CanvasField.isPanning) { - e.preventDefault(); - } - }, - { passive: false }, - ); - - // Prevent page scrolling when middle mouse button is pressed - canvas.addEventListener("mousedown", (e) => { - if (e.button === 1 && e.target === canvas) { - // Middle mouse button - e.preventDefault(); - CanvasField.startPanning(e); - } - }); - - // Add mouseup handler for panning - canvas.addEventListener("mouseup", (e) => { - if (e.button === 1 && e.target === canvas) { - CanvasField.stopPanning(); - } - }); - - // Configure Coloris - Coloris({ - theme: "polaroid", - themeMode: "light", - alpha: false, - formatToggle: false, - swatches: [ - "#2563eb", // Default blue - "#000000", - "#ffffff", - "#db4437", - "#4285f4", - "#0f9d58", - "#ffeb3b", - "#ff7f00", - ], - }); - - // Tool buttons - const toolButtons = { - select: document.getElementById("selectTool"), - pen: document.getElementById("penTool"), - rectangle: document.getElementById("rectangleTool"), - circle: document.getElementById("circleTool"), - line: document.getElementById("lineTool"), - arrow: document.getElementById("arrowTool"), - hexagon: document.getElementById("hexagonTool"), - star: document.getElementById("starTool"), - }; - - // Function to update active tool button - function updateActiveToolButton(activeTool) { - Object.entries(toolButtons).forEach(([tool, button]) => { - if (tool === activeTool) { - button.classList.add("active"); - } else { - button.classList.remove("active"); - } - }); - } - - // Add tool button event listeners - Object.entries(toolButtons).forEach(([tool, button]) => { - button.addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.setTool(tool); - updateActiveToolButton(tool); - }); - }); - - // Color picker - document - .getElementById("pathColorPicker") - .addEventListener("change", function (e) { - CanvasField.setColor(this.value); - }); - - // Thickness control - const thicknessSlider = document.getElementById("pathThickness"); - const thicknessValue = document.getElementById("pathThicknessValue"); - - thicknessSlider.addEventListener("input", function () { - const { value } = this; - thicknessValue.textContent = value; - CanvasField.setThickness(parseInt(value)); - }); - - // Fill toggle button - const fillToggleBtn = document.getElementById("fillToggle"); - fillToggleBtn.addEventListener("click", function (e) { - e.preventDefault(); // Prevent form submission - const newFillState = !CanvasField.isFilled; - CanvasField.setFill(newFillState); - this.textContent = `Fill: ${newFillState ? "On" : "Off"}`; - this.classList.toggle("bg-blue-800", newFillState); - }); - - // Function to update hidden path data - function updatePathData() { - const pathData = document.getElementById("autoPathData"); - if (pathData) { - pathData.value = JSON.stringify(CanvasField.drawingHistory); - } - } - - // Add mouseup listener to update path data after drawing - canvas.addEventListener("mouseup", updatePathData); - - // Undo button - document.getElementById("undoPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.undo(); - updatePathData(); - }); - - // Redo button - document.getElementById("redoPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.redo(); - updatePathData(); - }); - - // Clear button - document.getElementById("clearPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - if (confirm("Are you sure you want to clear the path?")) { - CanvasField.clear(); - updatePathData(); - } - }); - - // Save button - document.getElementById("savePath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - const jsonString = JSON.stringify(CanvasField.drawingHistory); - const blob = new Blob([jsonString], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - CanvasField.showStatus("Path saved"); - }); - - // Load button and file input - const loadBtn = document.getElementById("loadPath"); - const loadFile = document.getElementById("loadFile"); - - loadBtn.addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - loadFile.click(); - }); - - // Reset view button - document.getElementById("goHome").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.resizeCanvas(); - CanvasField.resetView(); - CanvasField.redrawCanvas(); - CanvasField.showStatus("View reset to origin"); - }); - - // Flip and rotate buttons - document.getElementById("flipHorizontal").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.flipHorizontal(); - }); - - document.getElementById("flipVertical").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.flipVertical(); - }); - - document.getElementById("rotateCw").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.rotateClockwise(); - }); - - document.getElementById("rotateCcw").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.rotateCounterClockwise(); - }); - - // Readonly toggle button - const readonlyToggle = document.getElementById("readonlyToggle"); - readonlyToggle.addEventListener("click", (e) => { - e.preventDefault(); - const newState = !CanvasField.readonly; - CanvasField.setReadonly(newState); - readonlyToggle.classList.toggle("bg-blue-800", newState); - readonlyToggle.classList.toggle("text-white", newState); - }); - - loadFile.addEventListener("change", (e) => { - if (e.target.files.length === 0) { - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - - reader.onload = function (event) { - try { - const pathData = JSON.parse(event.target.result); - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - updatePathData(); - CanvasField.showStatus("Path loaded"); - } catch (error) { - console.error("Error loading path:", error); - CanvasField.showStatus("Error loading path"); - } - }; - - reader.readAsText(file); - e.target.value = null; // Reset file input - }); - - // Add keyboard shortcuts - document.addEventListener("keydown", (e) => { - if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { - return; - } - - if (e.ctrlKey) { - switch (e.key.toLowerCase()) { - case "a": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("select"); - updateActiveToolButton("select"); - break; - case "p": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("pen"); - updateActiveToolButton("pen"); - break; - case "r": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("rectangle"); - updateActiveToolButton("rectangle"); - break; - case "c": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("circle"); - updateActiveToolButton("circle"); - break; - case "l": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("line"); - updateActiveToolButton("line"); - break; - case "h": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("hexagon"); - updateActiveToolButton("hexagon"); - break; - case "w": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("arrow"); - updateActiveToolButton("arrow"); - break; - case "s": - if (!e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("star"); - updateActiveToolButton("star"); - } - break; - case "z": - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - CanvasField.redo(); - } else if (!e.repeat) { - // Only trigger once when key is first pressed - CanvasField.undo(); - } - updatePathData(); - break; - case "y": - e.preventDefault(); - e.stopPropagation(); - CanvasField.redo(); - updatePathData(); - break; - case "f": - e.preventDefault(); - e.stopPropagation(); - fillToggleBtn.click(); - break; - } - } + const { updatePathData } = initScoutFormCanvas({ + updateUIControls, + resizeOnHome: true, }); // Form submission handling diff --git a/app/static/js/scout/edit.js b/app/static/js/scout/edit.js index 82490ac..cc4b2ec 100644 --- a/app/static/js/scout/edit.js +++ b/app/static/js/scout/edit.js @@ -1,158 +1,61 @@ // Single DOMContentLoaded event handler document.addEventListener("DOMContentLoaded", function () { - // Auto-capitalize event code - const eventCodeInput = document.querySelector('input[name="event_code"]'); - if (eventCodeInput) { - eventCodeInput.addEventListener("input", function (e) { - this.value = this.value.toUpperCase(); - }); - } - - // Initialize CanvasField helper - const CanvasField = new Canvas({ - canvas: document.getElementById("autoPath"), - container: document.getElementById("autoPathContainer"), - externalUpdateUIControls: updateUIControls, - showStatus: (message) => { - const flashContainer = document.querySelector(".container"); - if (!flashContainer) { - return; - } - - const messageDiv = document.createElement("div"); - messageDiv.className = - "fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up"; - - const innerDiv = document.createElement("div"); - innerDiv.className = - "flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200"; - - const icon = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - icon.setAttribute("class", "w-6 h-6 mr-3 flex-shrink-0"); - icon.setAttribute("fill", "currentColor"); - icon.setAttribute("viewBox", "0 0 20 20"); - - const path = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - path.setAttribute("fill-rule", "evenodd"); - path.setAttribute( - "d", - "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - ); - path.setAttribute("clip-rule", "evenodd"); - - icon.appendChild(path); - - const text = document.createElement("p"); - text.className = "text-base font-medium"; - text.textContent = message; - - const closeButton = document.createElement("button"); - closeButton.className = - "ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100"; - closeButton.onclick = () => messageDiv.remove(); - - const closeIcon = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - closeIcon.setAttribute("class", "w-5 h-5"); - closeIcon.setAttribute("fill", "currentColor"); - closeIcon.setAttribute("viewBox", "0 0 20 20"); - - const closePath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - closePath.setAttribute("fill-rule", "evenodd"); - closePath.setAttribute( - "d", - "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", - ); - closePath.setAttribute("clip-rule", "evenodd"); - - closeIcon.appendChild(closePath); - closeButton.appendChild(closeIcon); - - innerDiv.appendChild(icon); - innerDiv.appendChild(text); - innerDiv.appendChild(closeButton); - messageDiv.appendChild(innerDiv); - - flashContainer.appendChild(messageDiv); - - setTimeout(() => { - if (messageDiv.parentNode === flashContainer) { - messageDiv.remove(); - } - }, 3000); - }, - initialColor: "#2563eb", - initialThickness: 3, - maxPanDistance: 1000, - backgroundImage: "/static/images/field-2026.png", - readonly: false, - }); - - // Verify background image loading - const testImage = new Image(); - testImage.onload = () => { - console.log("Background image loaded successfully"); - }; - testImage.onerror = () => { - console.error("Failed to load background image"); - CanvasField.showStatus("Error loading field image"); - }; - testImage.src = "/static/images/field-2026.png"; - - // Load existing path data if available - const pathDataInput = document.getElementById("autoPathData"); - if (pathDataInput && pathDataInput.value) { - try { - let sanitizedValue = pathDataInput.value.trim(); - - // Remove any potential HTML entities - sanitizedValue = sanitizedValue - .replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); - - // Check if the value is actually JSON - if ( - sanitizedValue && - sanitizedValue !== "None" && - sanitizedValue !== "null" - ) { - // Try to fix common JSON formatting issues - if (sanitizedValue.startsWith("'") && sanitizedValue.endsWith("'")) { - sanitizedValue = sanitizedValue.slice(1, -1); - } - - // Convert single quotes to double quotes - sanitizedValue = sanitizedValue.replace(/'/g, '"'); - - // Convert Python boolean values to JSON boolean values - sanitizedValue = sanitizedValue - .replace(/: True/g, ": true") - .replace(/: False/g, ": false"); - + const { updatePathData } = initScoutFormCanvas({ + updateUIControls, + // Load existing path data if available + onCanvasReady(CanvasField) { + const pathDataInput = document.getElementById("autoPathData"); + if (pathDataInput && pathDataInput.value) { try { - const pathData = JSON.parse(sanitizedValue); - - if (Array.isArray(pathData)) { - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - CanvasField.showStatus("Existing path loaded"); + let sanitizedValue = pathDataInput.value.trim(); + + // Remove any potential HTML entities + sanitizedValue = sanitizedValue + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + // Check if the value is actually JSON + if ( + sanitizedValue && + sanitizedValue !== "None" && + sanitizedValue !== "null" + ) { + // Try to fix common JSON formatting issues + if (sanitizedValue.startsWith("'") && sanitizedValue.endsWith("'")) { + sanitizedValue = sanitizedValue.slice(1, -1); + } + + // Convert single quotes to double quotes + sanitizedValue = sanitizedValue.replace(/'/g, '"'); + + // Convert Python boolean values to JSON boolean values + sanitizedValue = sanitizedValue + .replace(/: True/g, ": true") + .replace(/: False/g, ": false"); + + try { + const pathData = JSON.parse(sanitizedValue); + + if (Array.isArray(pathData)) { + CanvasField.drawingHistory = pathData; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Existing path loaded"); + } else { + CanvasField.drawingHistory = []; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Invalid path data format"); + } + } catch (error) { + console.error("Error loading existing path:", error); + CanvasField.drawingHistory = []; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Error loading existing path"); + } } else { CanvasField.drawingHistory = []; CanvasField.redrawCanvas(); - CanvasField.showStatus("Invalid path data format"); } } catch (error) { console.error("Error loading existing path:", error); @@ -160,329 +63,8 @@ document.addEventListener("DOMContentLoaded", function () { CanvasField.redrawCanvas(); CanvasField.showStatus("Error loading existing path"); } - } else { - CanvasField.drawingHistory = []; - CanvasField.redrawCanvas(); - } - } catch (error) { - console.error("Error loading existing path:", error); - CanvasField.drawingHistory = []; - CanvasField.redrawCanvas(); - CanvasField.showStatus("Error loading existing path"); - } - } - - // Prevent page scrolling when using mouse wheel on canvas - const canvas = document.getElementById("autoPath"); - canvas.addEventListener( - "wheel", - (e) => { - if (e.target === canvas && CanvasField.isPanning) { - e.preventDefault(); } }, - { passive: false }, - ); - - // Prevent page scrolling when middle mouse button is pressed - canvas.addEventListener("mousedown", (e) => { - if (e.button === 1 && e.target === canvas) { - // Middle mouse button - e.preventDefault(); - CanvasField.startPanning(e); - } - }); - - // Add mouseup handler for panning - canvas.addEventListener("mouseup", (e) => { - if (e.button === 1 && e.target === canvas) { - CanvasField.stopPanning(); - } - }); - - // Configure Coloris - Coloris({ - theme: "polaroid", - themeMode: "light", - alpha: false, - formatToggle: false, - swatches: [ - "#2563eb", // Default blue - "#000000", - "#ffffff", - "#db4437", - "#4285f4", - "#0f9d58", - "#ffeb3b", - "#ff7f00", - ], - }); - - // Tool buttons - const toolButtons = { - select: document.getElementById("selectTool"), - pen: document.getElementById("penTool"), - rectangle: document.getElementById("rectangleTool"), - circle: document.getElementById("circleTool"), - line: document.getElementById("lineTool"), - arrow: document.getElementById("arrowTool"), - hexagon: document.getElementById("hexagonTool"), - star: document.getElementById("starTool"), - }; - - // Function to update active tool button - function updateActiveToolButton(activeTool) { - Object.entries(toolButtons).forEach(([tool, button]) => { - if (tool === activeTool) { - button.classList.add("active"); - } else { - button.classList.remove("active"); - } - }); - } - - // Add tool button event listeners - Object.entries(toolButtons).forEach(([tool, button]) => { - button.addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.setTool(tool); - updateActiveToolButton(tool); - }); - }); - - // Color picker - document - .getElementById("pathColorPicker") - .addEventListener("change", function (e) { - CanvasField.setColor(this.value); - }); - - // Thickness control - const thicknessSlider = document.getElementById("pathThickness"); - const thicknessValue = document.getElementById("pathThicknessValue"); - - thicknessSlider.addEventListener("input", function () { - const { value } = this; - thicknessValue.textContent = value; - CanvasField.setThickness(parseInt(value)); - }); - - // Fill toggle button - const fillToggleBtn = document.getElementById("fillToggle"); - fillToggleBtn.addEventListener("click", function (e) { - e.preventDefault(); // Prevent form submission - const newFillState = !CanvasField.isFilled; - CanvasField.setFill(newFillState); - this.textContent = `Fill: ${newFillState ? "On" : "Off"}`; - this.classList.toggle("bg-blue-800", newFillState); - }); - - // Function to update hidden path data - function updatePathData() { - const pathData = document.getElementById("autoPathData"); - if (pathData) { - pathData.value = JSON.stringify(CanvasField.drawingHistory); - } - } - - // Add mouseup listener to update path data after drawing - canvas.addEventListener("mouseup", updatePathData); - - // Undo button - document.getElementById("undoPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.undo(); - updatePathData(); - }); - - // Redo button - document.getElementById("redoPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.redo(); - updatePathData(); - }); - - // Clear button - document.getElementById("clearPath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - if (confirm("Are you sure you want to clear the path?")) { - CanvasField.clear(); - updatePathData(); - } - }); - - // Save button - document.getElementById("savePath").addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - const jsonString = JSON.stringify(CanvasField.drawingHistory); - const blob = new Blob([jsonString], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - CanvasField.showStatus("Path saved"); - }); - - // Load button and file input - const loadBtn = document.getElementById("loadPath"); - const loadFile = document.getElementById("loadFile"); - - loadBtn.addEventListener("click", (e) => { - e.preventDefault(); // Prevent form submission - loadFile.click(); - }); - - // Reset view button - document.getElementById("goHome").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.resetView(); - CanvasField.redrawCanvas(); - CanvasField.showStatus("View reset to origin"); - }); - - // Flip and rotate buttons - document.getElementById("flipHorizontal").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.flipHorizontal(); - }); - - document.getElementById("flipVertical").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.flipVertical(); - }); - - document.getElementById("rotateCw").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.rotateClockwise(); - }); - - document.getElementById("rotateCcw").addEventListener("click", (e) => { - e.preventDefault(); - CanvasField.rotateCounterClockwise(); - }); - - // Readonly toggle button - const readonlyToggle = document.getElementById("readonlyToggle"); - readonlyToggle.addEventListener("click", (e) => { - e.preventDefault(); - const newState = !CanvasField.readonly; - CanvasField.setReadonly(newState); - readonlyToggle.classList.toggle("bg-blue-800", newState); - readonlyToggle.classList.toggle("text-white", newState); - }); - - loadFile.addEventListener("change", (e) => { - if (e.target.files.length === 0) { - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - - reader.onload = function (event) { - try { - const pathData = JSON.parse(event.target.result); - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - updatePathData(); - CanvasField.showStatus("Path loaded"); - } catch (error) { - console.error("Error loading path:", error); - CanvasField.showStatus("Error loading path"); - } - }; - - reader.readAsText(file); - e.target.value = null; // Reset file input - }); - - // Add keyboard shortcuts - document.addEventListener("keydown", (e) => { - if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { - return; - } - - if (e.ctrlKey) { - switch (e.key.toLowerCase()) { - case "a": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("select"); - updateActiveToolButton("select"); - break; - case "p": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("pen"); - updateActiveToolButton("pen"); - break; - case "r": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("rectangle"); - updateActiveToolButton("rectangle"); - break; - case "c": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("circle"); - updateActiveToolButton("circle"); - break; - case "l": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("line"); - updateActiveToolButton("line"); - break; - case "h": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("hexagon"); - updateActiveToolButton("hexagon"); - break; - case "w": - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("arrow"); - updateActiveToolButton("arrow"); - break; - case "s": - if (!e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool("star"); - updateActiveToolButton("star"); - } - break; - case "z": - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - CanvasField.redo(); - } else if (!e.repeat) { - // Only trigger once when key is first pressed - CanvasField.undo(); - } - updatePathData(); - break; - case "y": - e.preventDefault(); - e.stopPropagation(); - CanvasField.redo(); - updatePathData(); - break; - case "f": - e.preventDefault(); - e.stopPropagation(); - fillToggleBtn.click(); - break; - } - } }); // Form submission handling diff --git a/app/static/js/scout/form-canvas.js b/app/static/js/scout/form-canvas.js new file mode 100644 index 0000000..97d0f37 --- /dev/null +++ b/app/static/js/scout/form-canvas.js @@ -0,0 +1,430 @@ +// Form setup, path canvas, toolbar and shortcuts shared by the add and edit scouting forms. +// updateUIControls is the page's own handler for syncing the colour/thickness controls. +function initScoutFormCanvas({ updateUIControls, onCanvasReady = () => {}, resizeOnHome = false }) { + const eventCodeInput = document.querySelector('input[name="event_code"]'); + if (eventCodeInput) { + eventCodeInput.addEventListener("input", function (e) { + this.value = this.value.toUpperCase(); + }); + } + + // Initialize CanvasField helper + const CanvasField = new Canvas({ + canvas: document.getElementById("autoPath"), + container: document.getElementById("autoPathContainer"), + externalUpdateUIControls: updateUIControls, + showStatus: (message) => { + const flashContainer = document.querySelector(".container"); + if (!flashContainer) { + return; + } + + const messageDiv = document.createElement("div"); + messageDiv.className = + "fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up"; + + const innerDiv = document.createElement("div"); + innerDiv.className = + "flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200"; + + const icon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + icon.setAttribute("class", "w-6 h-6 mr-3 flex-shrink-0"); + icon.setAttribute("fill", "currentColor"); + icon.setAttribute("viewBox", "0 0 20 20"); + + const path = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + path.setAttribute("fill-rule", "evenodd"); + path.setAttribute( + "d", + "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + ); + path.setAttribute("clip-rule", "evenodd"); + + icon.appendChild(path); + + const text = document.createElement("p"); + text.className = "text-base font-medium"; + text.textContent = message; + + const closeButton = document.createElement("button"); + closeButton.className = + "ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100"; + closeButton.onclick = () => messageDiv.remove(); + + const closeIcon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + closeIcon.setAttribute("class", "w-5 h-5"); + closeIcon.setAttribute("fill", "currentColor"); + closeIcon.setAttribute("viewBox", "0 0 20 20"); + + const closePath = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + closePath.setAttribute("fill-rule", "evenodd"); + closePath.setAttribute( + "d", + "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", + ); + closePath.setAttribute("clip-rule", "evenodd"); + + closeIcon.appendChild(closePath); + closeButton.appendChild(closeIcon); + + innerDiv.appendChild(icon); + innerDiv.appendChild(text); + innerDiv.appendChild(closeButton); + messageDiv.appendChild(innerDiv); + + flashContainer.appendChild(messageDiv); + + setTimeout(() => { + if (messageDiv.parentNode === flashContainer) { + messageDiv.remove(); + } + }, 3000); + }, + initialColor: "#2563eb", + initialThickness: 3, + maxPanDistance: 1000, + backgroundImage: "/static/images/field-2026.png", + readonly: false, + }); + + // Verify background image loading + const testImage = new Image(); + testImage.onload = () => { + console.log("Background image loaded successfully"); + }; + testImage.onerror = () => { + console.error("Failed to load background image"); + CanvasField.showStatus("Error loading field image"); + }; + testImage.src = "/static/images/field-2026.png"; + + onCanvasReady(CanvasField); + + // Prevent page scrolling when using mouse wheel on canvas + const canvas = document.getElementById("autoPath"); + canvas.addEventListener( + "wheel", + (e) => { + if (e.target === canvas && CanvasField.isPanning) { + e.preventDefault(); + } + }, + { passive: false }, + ); + + // Prevent page scrolling when middle mouse button is pressed + canvas.addEventListener("mousedown", (e) => { + if (e.button === 1 && e.target === canvas) { + // Middle mouse button + e.preventDefault(); + CanvasField.startPanning(e); + } + }); + + // Add mouseup handler for panning + canvas.addEventListener("mouseup", (e) => { + if (e.button === 1 && e.target === canvas) { + CanvasField.stopPanning(); + } + }); + + // Configure Coloris + Coloris({ + theme: "polaroid", + themeMode: "light", + alpha: false, + formatToggle: false, + swatches: [ + "#2563eb", // Default blue + "#000000", + "#ffffff", + "#db4437", + "#4285f4", + "#0f9d58", + "#ffeb3b", + "#ff7f00", + ], + }); + + // Tool buttons + const toolButtons = { + select: document.getElementById("selectTool"), + pen: document.getElementById("penTool"), + rectangle: document.getElementById("rectangleTool"), + circle: document.getElementById("circleTool"), + line: document.getElementById("lineTool"), + arrow: document.getElementById("arrowTool"), + hexagon: document.getElementById("hexagonTool"), + star: document.getElementById("starTool"), + }; + + // Function to update active tool button + function updateActiveToolButton(activeTool) { + Object.entries(toolButtons).forEach(([tool, button]) => { + if (tool === activeTool) { + button.classList.add("active"); + } else { + button.classList.remove("active"); + } + }); + } + + // Add tool button event listeners + Object.entries(toolButtons).forEach(([tool, button]) => { + button.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.setTool(tool); + updateActiveToolButton(tool); + }); + }); + + // Color picker + document + .getElementById("pathColorPicker") + .addEventListener("change", function (e) { + CanvasField.setColor(this.value); + }); + + // Thickness control + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessValue = document.getElementById("pathThicknessValue"); + + thicknessSlider.addEventListener("input", function () { + const { value } = this; + thicknessValue.textContent = value; + CanvasField.setThickness(parseInt(value)); + }); + + // Fill toggle button + const fillToggleBtn = document.getElementById("fillToggle"); + fillToggleBtn.addEventListener("click", function (e) { + e.preventDefault(); // Prevent form submission + const newFillState = !CanvasField.isFilled; + CanvasField.setFill(newFillState); + this.textContent = `Fill: ${newFillState ? "On" : "Off"}`; + this.classList.toggle("bg-blue-800", newFillState); + }); + + // Function to update hidden path data + function updatePathData() { + const pathData = document.getElementById("autoPathData"); + if (pathData) { + pathData.value = JSON.stringify(CanvasField.drawingHistory); + } + } + + // Add mouseup listener to update path data after drawing + canvas.addEventListener("mouseup", updatePathData); + + // Undo button + document.getElementById("undoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.undo(); + updatePathData(); + }); + + // Redo button + document.getElementById("redoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.redo(); + updatePathData(); + }); + + // Clear button + document.getElementById("clearPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + if (confirm("Are you sure you want to clear the path?")) { + CanvasField.clear(); + updatePathData(); + } + }); + + // Save button + document.getElementById("savePath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + const jsonString = JSON.stringify(CanvasField.drawingHistory); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + CanvasField.showStatus("Path saved"); + }); + + // Load button and file input + const loadBtn = document.getElementById("loadPath"); + const loadFile = document.getElementById("loadFile"); + + loadBtn.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + loadFile.click(); + }); + + // Reset view button + document.getElementById("goHome").addEventListener("click", (e) => { + e.preventDefault(); + if (resizeOnHome) CanvasField.resizeCanvas(); + CanvasField.resetView(); + CanvasField.redrawCanvas(); + CanvasField.showStatus("View reset to origin"); + }); + + // Flip and rotate buttons + document.getElementById("flipHorizontal").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipHorizontal(); + }); + + document.getElementById("flipVertical").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipVertical(); + }); + + document.getElementById("rotateCw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateClockwise(); + }); + + document.getElementById("rotateCcw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateCounterClockwise(); + }); + + // Readonly toggle button + const readonlyToggle = document.getElementById("readonlyToggle"); + readonlyToggle.addEventListener("click", (e) => { + e.preventDefault(); + const newState = !CanvasField.readonly; + CanvasField.setReadonly(newState); + readonlyToggle.classList.toggle("bg-blue-800", newState); + readonlyToggle.classList.toggle("text-white", newState); + }); + + loadFile.addEventListener("change", (e) => { + if (e.target.files.length === 0) { + return; + } + + const file = e.target.files[0]; + const reader = new FileReader(); + + reader.onload = function (event) { + try { + const pathData = JSON.parse(event.target.result); + CanvasField.drawingHistory = pathData; + CanvasField.redrawCanvas(); + updatePathData(); + CanvasField.showStatus("Path loaded"); + } catch (error) { + console.error("Error loading path:", error); + CanvasField.showStatus("Error loading path"); + } + }; + + reader.readAsText(file); + e.target.value = null; // Reset file input + }); + + // Add keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { + return; + } + + if (e.ctrlKey) { + switch (e.key.toLowerCase()) { + case "a": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("select"); + updateActiveToolButton("select"); + break; + case "p": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("pen"); + updateActiveToolButton("pen"); + break; + case "r": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("rectangle"); + updateActiveToolButton("rectangle"); + break; + case "c": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("circle"); + updateActiveToolButton("circle"); + break; + case "l": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("line"); + updateActiveToolButton("line"); + break; + case "h": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("hexagon"); + updateActiveToolButton("hexagon"); + break; + case "w": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("arrow"); + updateActiveToolButton("arrow"); + break; + case "s": + if (!e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("star"); + updateActiveToolButton("star"); + } + break; + case "z": + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + CanvasField.redo(); + } else if (!e.repeat) { + // Only trigger once when key is first pressed + CanvasField.undo(); + } + updatePathData(); + break; + case "y": + e.preventDefault(); + e.stopPropagation(); + CanvasField.redo(); + updatePathData(); + break; + case "f": + e.preventDefault(); + e.stopPropagation(); + fillToggleBtn.click(); + break; + } + } + }); + + return { updatePathData }; +} diff --git a/app/static/js/service-worker.js b/app/static/js/service-worker.js index 9f74b57..35f4749 100644 --- a/app/static/js/service-worker.js +++ b/app/static/js/service-worker.js @@ -1,9 +1,9 @@ -const CACHE_NAME = 'castle-app-v10'; -const STATIC_CACHE_NAME = 'castle-app-static-v10'; -const DYNAMIC_CACHE_NAME = 'castle-app-dynamic-v10'; -const PAGE_CACHE_NAME = 'castle-app-pages-v10'; +const CACHE_NAME = 'castle-app-v11'; +const STATIC_CACHE_NAME = 'castle-app-static-v11'; +const DYNAMIC_CACHE_NAME = 'castle-app-dynamic-v11'; +const PAGE_CACHE_NAME = 'castle-app-pages-v11'; -const CACHE_VERSION = '11'; // Increment this when making changes +const CACHE_VERSION = '12'; // Increment this when making changes // Add timestamped version query parameter to bust cache const CACHE_TIMESTAMP = new Date().getTime(); @@ -15,12 +15,14 @@ const STATIC_ASSETS = [ '/static/js/Canvas.js', '/static/js/team-stats.js', '/static/js/scout/add.js', + '/static/js/scout/form-canvas.js', '/static/js/scout/list.js', '/static/js/scout/fuel-input.js', '/static/js/scout/fuel-estimates.js', '/static/images/field-2026.png', '/static/images/default_profile.png', '/static/js/notifications.js', + '/static/js/base.js', '/static/logo.png', '/static/manifest.json', '/static/icons/icon-192x192.png', diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html index becd3bf..6e2b7c8 100644 --- a/app/templates/auth/settings.html +++ b/app/templates/auth/settings.html @@ -447,359 +447,5 @@
Delete Account
- + {% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 6e3c767..bed7d3b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -367,284 +367,6 @@ - + diff --git a/app/templates/scouting/add.html b/app/templates/scouting/add.html index 62fdfbd..b59fd0e 100644 --- a/app/templates/scouting/add.html +++ b/app/templates/scouting/add.html @@ -1121,6 +1121,7 @@

+ {% endblock %} diff --git a/app/templates/scouting/edit.html b/app/templates/scouting/edit.html index 071c2d9..ea97e80 100644 --- a/app/templates/scouting/edit.html +++ b/app/templates/scouting/edit.html @@ -624,5 +624,6 @@

Addition + {% endblock %} diff --git a/tests/test_browser_resilience.js b/tests/test_browser_resilience.js index 8cb8257..211f0d0 100644 --- a/tests/test_browser_resilience.js +++ b/tests/test_browser_resilience.js @@ -52,9 +52,9 @@ test('failed writes remain failures rather than successful offline responses', a }); function pageFetch(network, online) { - const source = fs.readFileSync('app/templates/base.html', 'utf8'); - const start = source.indexOf(' const originalFetch ='); - const end = source.indexOf('\n };', start) + '\n };'.length; + const source = fs.readFileSync('app/static/js/base.js', 'utf8'); + const start = source.indexOf(' const originalFetch ='); + const end = source.indexOf('\n };', start) + '\n };'.length; const storage = new Map([['offlineMode', 'true']]); const scope = { window: {fetch: network}, navigator: {onLine: online}, From be1f6d09fc720ff133000436cad68d92906b9293 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:50:36 -0400 Subject: [PATCH 09/23] Enable CSRF protection, require SECRET_KEY, drop wildcard CORS Security fixes (behaviour changes): - CSRFProtect is enabled. Every POST form carries a csrf_token field, and a fetch wrapper at the top of base.html adds X-CSRFToken to same-origin writes. Tokens live for the session (no one-hour limit) because scouting pages stay open all event and queue offline. update_assignment_status is exempt: the service worker's "mark complete" action cannot read the token, and the route only accepts JSON, which browsers will not send cross-site without a preflight. Rejected requests get "session expired" (JSON 400 or flash+redirect). - SECRET_KEY no longer falls back to the public "team334"; the app refuses to start without it, since anyone knowing the key can forge a session for any user. - Session and remember-me cookies are SameSite=Lax. - Flask-CORS with origins "*" is removed; nothing calls the app cross-origin (the standalone app is a WebView on the site itself). - Record delete, pit delete and team leave are POST-only; deletes were GET links that any cross-site link could trigger. - The catch-all exception handler now passes HTTP errors (400/405/415) through instead of turning them into a 500 page. Co-Authored-By: Claude Opus 5.5 --- app/app.py | 47 ++++++++-- app/scout/routes/pit.py | 2 +- app/scout/routes/records.py | 2 +- app/team/routes.py | 2 +- app/templates/auth/login.html | 1 + app/templates/auth/register.html | 1 + app/templates/auth/settings.html | 1 + app/templates/base.html | 19 ++++ app/templates/scouting/_match_list.html | 20 ++-- app/templates/scouting/add.html | 1 + app/templates/scouting/edit.html | 1 + app/templates/scouting/pit-scouting-add.html | 1 + app/templates/scouting/pit-scouting-edit.html | 1 + app/templates/scouting/pit-scouting.html | 6 +- app/templates/team/join.html | 1 + app/templates/team/manage.html | 2 + app/templates/team/settings.html | 1 + requirements.txt | 1 - tests/test_browser_resilience.js | 25 +++++ tests/test_security.py | 92 +++++++++++++++++++ 20 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 tests/test_security.py diff --git a/app/app.py b/app/app.py index 6e2d5b2..99044fc 100644 --- a/app/app.py +++ b/app/app.py @@ -2,16 +2,17 @@ import os import traceback from time import strftime +from urllib.parse import urlparse from dotenv import load_dotenv -from flask import (Flask, make_response, redirect, render_template, request, - send_from_directory, url_for) -from flask_cors import CORS +from flask import (Flask, flash, jsonify, make_response, redirect, + render_template, request, send_from_directory, url_for) from flask_login import LoginManager, current_user -from flask_wtf.csrf import CSRFProtect +from flask_wtf.csrf import CSRFError, CSRFProtect +from werkzeug.exceptions import HTTPException from app.auth.auth_utils import UserManager -from app.utils import limiter +from app.utils import is_safe_url, limiter csrf = CSRFProtect() login_manager = LoginManager() @@ -23,11 +24,24 @@ def create_app(): # Load config load_dotenv() + secret_key = os.getenv("SECRET_KEY") + if not secret_key: + # Sessions are signed with this key; a public default lets anyone forge a login. + raise RuntimeError( + "SECRET_KEY is not set. Add it to the environment or .env, e.g. " + "SECRET_KEY=$(python -c \"import secrets; print(secrets.token_hex(32))\")") app.config.update( - SECRET_KEY=os.getenv("SECRET_KEY", "team334"), + SECRET_KEY=secret_key, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SAMESITE="Lax", + REMEMBER_COOKIE_HTTPONLY=True, + REMEMBER_COOKIE_SECURE=True, + REMEMBER_COOKIE_SAMESITE="Lax", WTF_CSRF_ENABLED=True, + # Scouting pages stay open all event and queue submissions offline; a + # token is valid for the whole session rather than expiring after an hour. + WTF_CSRF_TIME_LIMIT=None, MONGO_URI=os.getenv("MONGO_URI", "mongodb://localhost:27017/scouting_app"), RATELIMIT_STORAGE_URI=os.getenv("MONGO_URI", "mongodb://localhost:27017/scouting_app"), VAPID_PUBLIC_KEY=os.getenv("VAPID_PUBLIC_KEY", ""), @@ -40,9 +54,8 @@ def create_app(): else: app.logger.info("VAPID keys configured properly.") - # csrf.init_app(app) + csrf.init_app(app) limiter.init_app(app) - CORS(app, resources={r"/*": {"origins": "*", "methods": ["GET", "POST", "PUT", "DELETE"]}}) login_manager.init_app(app) login_manager.login_view = "auth.login" @@ -74,6 +87,11 @@ def load_user(user_id): app.register_blueprint(team_bp, url_prefix="/team") app.register_blueprint(notifications_bp, url_prefix="/notifications") + # The service worker's "mark complete" notification action cannot read the + # page's CSRF token. The route only accepts JSON, which browsers will not + # send cross-site without a CORS preflight this app never grants. + csrf.exempt("app.team.routes.update_assignment_status") + @app.route("/") def index(): return render_template("index.html") @@ -89,11 +107,24 @@ def server_error(e): @app.errorhandler(Exception) def handle_exception(e): + if isinstance(e, HTTPException): + # 400/405/415 etc. are client errors with their own responses, not crashes + return e trace = traceback.format_exc() timestamp = strftime('[%Y-%b-%d %H:%M]') app.logger.error(f"{timestamp} User: {current_user.username if current_user.is_authenticated else 'Anonymous'} Unhandled exception: {str(e)}\nTraceback: {trace}", exc_info=True) return render_template("500.html"), 500 + @app.errorhandler(CSRFError) + def csrf_error(e): + app.logger.warning(f"CSRF rejected {request.method} {request.path}: {e.description}") + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": "Your session expired. Reload the page and try again."}), 400 + flash("Your session expired. Please try again.", "error") + referrer = urlparse(request.referrer or "") + back = referrer.path + (f"?{referrer.query}" if referrer.query else "") + return redirect(back if referrer.netloc == request.host and is_safe_url(back) else url_for("index")) + @app.errorhandler(429) def rate_limit_error(e): return render_template("429.html"), 429 diff --git a/app/scout/routes/pit.py b/app/scout/routes/pit.py index 61bc949..c66778f 100644 --- a/app/scout/routes/pit.py +++ b/app/scout/routes/pit.py @@ -131,7 +131,7 @@ def pit_scouting_edit(entry_id): return render_template("scouting/pit-scouting-edit.html", entry=pit_data) -@scouting_bp.route("/scouting/pit/delete/") +@scouting_bp.route("/scouting/pit/delete/", methods=["POST"]) @login_required def pit_scouting_delete(entry_id): if common.scouting_manager.delete_pit_scouting(entry_id, current_user.get_id()): diff --git a/app/scout/routes/records.py b/app/scout/routes/records.py index c0bf280..ae5ce5d 100644 --- a/app/scout/routes/records.py +++ b/app/scout/routes/records.py @@ -179,7 +179,7 @@ def edit(id): return redirect(url_for("scouting.home")) -@scouting_bp.route("/scouting/delete/") +@scouting_bp.route("/scouting/delete/", methods=["POST"]) @login_required def delete(id): try: diff --git a/app/team/routes.py b/app/team/routes.py index 3510c5c..40d8a43 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -123,7 +123,7 @@ def create(): return render_template("team/create.html", form=form) -@team_bp.route("//leave", methods=["GET", "POST"]) +@team_bp.route("//leave", methods=["POST"]) @login_required @limiter.limit("5 per minute") def leave(team_number): diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 857a2ad..168b910 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -17,6 +17,7 @@

+
diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index 5591c2a..d6340bf 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -21,6 +21,7 @@

+
diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html index 6e2b7c8..8579b3a 100644 --- a/app/templates/auth/settings.html +++ b/app/templates/auth/settings.html @@ -35,6 +35,7 @@

Profile Settings

+
diff --git a/app/templates/base.html b/app/templates/base.html index bed7d3b..8804773 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,6 +3,25 @@ + + {% block title %}Castle{% endblock %} diff --git a/app/templates/scouting/_match_list.html b/app/templates/scouting/_match_list.html index 82b557d..280332b 100644 --- a/app/templates/scouting/_match_list.html +++ b/app/templates/scouting/_match_list.html @@ -333,16 +333,12 @@

id="scoutingForm" autocomplete="off" > +

When all scored-fuel counts are zero or blank, Castle uses automatic Match13 estimates when results become available. diff --git a/app/templates/scouting/edit.html b/app/templates/scouting/edit.html index ea97e80..7abba39 100644 --- a/app/templates/scouting/edit.html +++ b/app/templates/scouting/edit.html @@ -34,6 +34,7 @@

Edit Match Scout

{% endif %}
+

Basic Information

diff --git a/app/templates/scouting/pit-scouting-add.html b/app/templates/scouting/pit-scouting-add.html index 7744de0..ab08cf2 100644 --- a/app/templates/scouting/pit-scouting-add.html +++ b/app/templates/scouting/pit-scouting-add.html @@ -23,6 +23,7 @@

Add Pit Scouting
+

Basic Information

diff --git a/app/templates/scouting/pit-scouting-edit.html b/app/templates/scouting/pit-scouting-edit.html index bbdf933..8fef8ac 100644 --- a/app/templates/scouting/pit-scouting-edit.html +++ b/app/templates/scouting/pit-scouting-edit.html @@ -23,6 +23,7 @@

Edit Pit Scoutin
+

Basic Information

diff --git a/app/templates/scouting/pit-scouting.html b/app/templates/scouting/pit-scouting.html index 32cc64b..0dba92f 100644 --- a/app/templates/scouting/pit-scouting.html +++ b/app/templates/scouting/pit-scouting.html @@ -182,14 +182,12 @@

Pit Scouting Dat Edit - +

diff --git a/app/templates/team/join.html b/app/templates/team/join.html index 0a0cad3..33691c4 100644 --- a/app/templates/team/join.html +++ b/app/templates/team/join.html @@ -6,6 +6,7 @@

Join a Team

+
Team Members

+