diff --git a/app/app.py b/app/app.py index 67d3fbf..9227b0f 100644 --- a/app/app.py +++ b/app/app.py @@ -11,7 +11,7 @@ from flask_wtf.csrf import CSRFProtect from app.auth.auth_utils import UserManager -from app.utils import limiter, get_database_connection, release_connection, force_close_connection +from app.utils import limiter, get_mongodb_instance, close_mongodb_connection csrf = CSRFProtect() mongo = PyMongo() @@ -58,9 +58,9 @@ def create_app(): app.db_managers = {} with app.app_context(): - # Get a shared MongoDB connection - conn = get_database_connection(app.config["MONGO_URI"]) - db = conn['db'] + # 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(): @@ -76,20 +76,14 @@ def create_app(): if "assignment_subscriptions" not in db.list_collection_names(): db.create_collection("assignment_subscriptions") - # Store the connection in app context for reuse - app.db_connection = conn - - # Release the initial connection reference - release_connection() - login_manager.init_app(app) login_manager.login_view = "auth.login" login_manager.login_message_category = "error" - # Initialize UserManager with the shared connection + # Initialize UserManager with the singleton connection try: - # Create user manager with existing connection - user_manager = UserManager(app.config["MONGO_URI"], existing_connection=app.db_connection) + # 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 @@ -180,92 +174,15 @@ def serve_root_service_worker(): @app.route('/offline.html') def offline(): return render_template('offline.html') - + + # Handle application shutdown to close the singleton MongoDB connection @app.teardown_appcontext - def close_db_connections(exception=None): - """Release database connections when the app context ends, but only if they were accessed""" - # Only log at debug level since this happens for every request - logger.debug("App context teardown - checking database connections") - - # Only clean up connections if the request actually accessed the database - # We can check if g has certain attributes to determine this - if not hasattr(g, 'db_accessed'): - logger.debug("No database access detected for this request, skipping connection cleanup") - return - - # Clean up the user manager if it exists - if hasattr(current_app, 'user_manager') and current_app.user_manager: - try: - current_app.user_manager.close() - logger.info("Closed user_manager database connection") - except Exception as e: - logger.error(f"Error closing user_manager connection: {str(e)}") - - # Clean up all database managers - if hasattr(current_app, 'db_managers'): - for name, manager in list(current_app.db_managers.items()): - try: - if manager and hasattr(manager, 'close'): - # Special handling for notification manager to stop the service - if name == 'notification' and hasattr(manager, 'stop_notification_service'): - manager.stop_notification_service() - logger.info("Notification service stopped during app shutdown") - - # Close the database connection - manager.close() - logger.info(f"Closed {name} database manager connection") - except Exception as e: - logger.error(f"Error closing {name} connection: {str(e)}") - - # Clear the managers dictionary - current_app.db_managers = {} - - # Final check of global connection pool during actual application shutdown - # (not just request end) - we can detect this by checking if the app is tearing down + def teardown_db_connection(exception=None): + """Close the singleton MongoDB connection only during application shutdown""" if exception is not None and isinstance(exception, Exception): - from app.utils import _GLOBAL_DB_CONNECTION - if _GLOBAL_DB_CONNECTION['count'] > 0: - logger.warning(f"Connection pool still has {_GLOBAL_DB_CONNECTION['count']} references at shutdown") - # Force close the connection during application teardown - force_close_connection() - - # Setup notification service shutdown for app termination - @app.after_request - def initialize_notification_cleanup(response): - """Initialize notification cleanup on first request""" - # Only run once by using an attribute check - if not hasattr(app, '_notification_cleanup_initialized'): - app._notification_cleanup_initialized = True - - try: - # Ensure we only register this once - from app.notifications.routes import notification_manager - - if 'notification' in app.db_managers and app.db_managers['notification']: - logger.info("Notification service already registered for cleanup") - else: - # Make sure notification manager is registered for cleanup - logger.info("Registering notification service for cleanup") - app.db_managers['notification'] = notification_manager - except ImportError: - logger.warning("Could not import notification_manager, skipping cleanup setup") - - return response - - # Since we're now using a global connection pool, let's add a periodic cleanup - @app.after_request - def check_db_connections(response): - """Occasionally check the global connection pool""" - # Only run this check occasionally (e.g., 1% of requests) to avoid overhead - if hash(str(time.time())) % 100 == 0: - from app.utils import _GLOBAL_DB_CONNECTION - logger.info(f"Current connection pool count: {_GLOBAL_DB_CONNECTION['count']}") - - # If count is high, it might indicate a leak - if _GLOBAL_DB_CONNECTION['count'] > 10: - logger.warning(f"High connection count detected: {_GLOBAL_DB_CONNECTION['count']}") - - return response + # Only close during actual application shutdown, not regular request teardown + close_mongodb_connection() + logger.info("Closed singleton MongoDB connection during application shutdown") return app diff --git a/app/auth/auth_utils.py b/app/auth/auth_utils.py index 7740fb2..a2cc937 100644 --- a/app/auth/auth_utils.py +++ b/app/auth/auth_utils.py @@ -1,277 +1,275 @@ -from __future__ import annotations - -import logging -from datetime import datetime, timezone - -from flask_login import current_user -from gridfs import GridFS -from werkzeug.security import generate_password_hash - -from app.models import User -from app.utils import DatabaseManager, allowed_file, with_mongodb_retry, get_database_connection - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -async def check_password_strength(password): - """ - Check if password meets minimum requirements: - - At least 8 characters - """ - if len(password) < 8: - return False, "Password must be at least 8 characters long" - return True, "Password meets all requirements" - - -class UserManager(DatabaseManager): - def __init__(self, mongo_uri, existing_connection=None): - # Use existing connection if provided, otherwise get shared connection - if existing_connection is None: - existing_connection = get_database_connection(mongo_uri) - super().__init__(mongo_uri, existing_connection=existing_connection) - self._ensure_collections() - - def _ensure_collections(self): - """Ensure required collections exist""" - if "users" not in self.db.list_collection_names(): - self.db.create_collection("users") - logger.info("Created users collection") - - @with_mongodb_retry(retries=3, delay=2) - async def create_user( - self, - email, - username, - password, - team_number=None - ): - """Create a new user with retry mechanism""" - self.ensure_connected() - try: - # Check for existing email - if self.db.users.find_one({"email": email}): - return False, "Email already registered" - - # Check for existing username - if self.db.users.find_one({"username": username}): - return False, "Username already taken" - - # Check password strength - password_valid, message = await check_password_strength(password) - if not password_valid: - return False, message - - # Create user document - user_data = { - "email": email, - "username": username, - "teamNumber": team_number, - "password_hash": generate_password_hash(password), - "created_at": datetime.now(timezone.utc), - "last_login": None, - "description": "", - "profile_picture_id": None, - } - - self.db.users.insert_one(user_data) - logger.info(f"Created new user: {username}") - return True, "User created successfully" - - except Exception as e: - logger.error(f"Error creating user: {str(e)}") - return False, "An internal error has occurred." - - @with_mongodb_retry(retries=3, delay=2) - async def authenticate_user(self, login, password): - """Authenticate user with retry mechanism""" - self.ensure_connected() - try: - if user_data := self.db.users.find_one( - {"$or": [{"email": login}, {"username": login}]} - ): - user = User.create_from_db(user_data) - if user and user.check_password(password): - # Update last login - self.db.users.update_one( - {"_id": user._id}, - {"$set": {"last_login": datetime.now(timezone.utc)}}, - ) - logger.info(f"Successful login: {login}") - return True, user - logger.warning(f"Failed login attempt: {login}") - return False, None - except Exception as e: - logger.error(f"Authentication error: {str(e)}") - return False, None - - def get_user_by_id(self, user_id): - """Retrieve user by ID with retry mechanism""" - self.ensure_connected() - try: - from bson.objectid import ObjectId - - user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) - return User.create_from_db(user_data) if user_data else None - except Exception as e: - logger.error(f"Error loading user: {str(e)}") - return None - - @with_mongodb_retry(retries=3, delay=2) - async def update_user_profile(self, user_id, updates): - """Update user profile information""" - self.ensure_connected() - try: - from bson.objectid import ObjectId - - # Filter out None values and empty strings - valid_updates = {k: v for k, v in updates.items() if v is not None and v != ""} - - # Check if username is being updated and is unique - if 'username' in valid_updates: - if existing_user := self.db.users.find_one( - { - "username": valid_updates['username'], - "_id": {"$ne": ObjectId(user_id)}, - } - ): - return False, "Username already taken" - - result = self.db.users.update_one( - {"_id": ObjectId(user_id)}, - {"$set": valid_updates} - ) - - if result.modified_count > 0: - return True, "Profile updated successfully" - return False, "No changes made" - - except Exception as e: - logger.error(f"Error updating profile: {str(e)}") - return False, "An internal error has occurred." - - def get_user_profile(self, username): - """Get user profile by username""" - self.ensure_connected() - try: - user_data = self.db.users.find_one({"username": username}) - return User.create_from_db(user_data) if user_data else None - except Exception as e: - logger.error(f"Error loading profile: {str(e)}") - return None - - @with_mongodb_retry(retries=3, delay=2) - async def update_profile_picture(self, user_id, file_id): - """Update user's profile picture and clean up old one""" - self.ensure_connected() - try: - from bson.objectid import ObjectId - from gridfs import GridFS - - # Get the old profile picture ID first - user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) - old_picture_id = user_data.get('profile_picture_id') if user_data else None - - # Update the profile picture ID - result = self.db.users.update_one( - {"_id": ObjectId(user_id)}, - {"$set": {"profile_picture_id": file_id}} - ) - - # If update was successful and there was an old picture, delete it - if result.modified_count > 0 and old_picture_id: - try: - fs = GridFS(self.db) - if fs.exists(ObjectId(old_picture_id)): - fs.delete(ObjectId(old_picture_id)) - logger.info(f"Deleted old profile picture: {old_picture_id}") - except Exception as e: - logger.error(f"Error deleting old profile picture: {str(e)}") - - return True, "Profile picture updated successfully" - - except Exception as e: - logger.error(f"Error updating profile picture: {str(e)}") - return False, "An internal error has occurred." - - def get_profile_picture(self, user_id): - """Get user's profile picture ID""" - self.ensure_connected() - try: - from bson.objectid import ObjectId - user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) - return user_data.get('profile_picture_id') if user_data else None - except Exception as e: - logger.error(f"Error getting profile picture: {str(e)}") - return None - - @with_mongodb_retry(retries=3, delay=2) - async def delete_user(self, user_id): - """Delete a user account and all associated data""" - self.ensure_connected() - try: - from bson.objectid import ObjectId - - # Get user data first - user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) - if not user_data: - return False, "User not found" - - # Delete profile picture if exists - if user_data.get('profile_picture_id'): - try: - fs = GridFS(self.db) - fs.delete(ObjectId(user_data['profile_picture_id'])) - except Exception as e: - logger.error(f"Error deleting profile picture: {str(e)}") - - # Delete user document - result = self.db.users.delete_one({"_id": ObjectId(user_id)}) - - if result.deleted_count > 0: - return True, "Account deleted successfully" - return False, "Failed to delete account" - - except Exception as e: - logger.error(f"Error deleting user: {str(e)}") - return False, "An internal error has occurred." - - @with_mongodb_retry(retries=3, delay=2) - async def update_user_settings(self, user_id, form_data, profile_picture=None): - """Update user settings including profile picture""" - self.ensure_connected() - try: - updates = {} - - # Handle username update if provided - if new_username := form_data.get('username'): - if new_username != current_user.username: - # Check if username is taken - if self.db.users.find_one({"username": new_username}): - return False - updates['username'] = new_username - - # Handle description update - if description := form_data.get('description'): - updates['description'] = description - - # Handle profile picture - if profile_picture and allowed_file(profile_picture.filename): - from werkzeug.utils import secure_filename - if profile_picture and allowed_file(profile_picture.filename): - fs = GridFS(self.db) - filename = secure_filename(profile_picture.filename) - file_id = fs.put( - profile_picture.stream.read(), - filename=filename, - content_type=profile_picture.content_type - ) - updates['profile_picture_id'] = file_id - - if updates: - success, message = await self.update_user_profile(user_id, updates) - return success - - return True, "Profile updated successfully" - except Exception as e: - logger.error(f"Error updating user settings: {str(e)}") - return False, "An internal error has occurred." +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from flask_login import current_user +from gridfs import GridFS +from werkzeug.security import generate_password_hash + +from app.models import User +from app.utils import DatabaseManager, allowed_file, with_mongodb_retry, get_database_connection + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +async def check_password_strength(password): + """ + Check if password meets minimum requirements: + - At least 8 characters + """ + if len(password) < 8: + return False, "Password must be at least 8 characters long" + return True, "Password meets all requirements" + + +class UserManager(DatabaseManager): + def __init__(self, mongo_uri=None): + super().__init__(mongo_uri) + self._ensure_collections() + + def _ensure_collections(self): + """Ensure required collections exist""" + if "users" not in self.db.list_collection_names(): + self.db.create_collection("users") + logger.info("Created users collection") + + @with_mongodb_retry(retries=3, delay=2) + async def create_user( + self, + email, + username, + password, + team_number=None + ): + """Create a new user with retry mechanism""" + self.ensure_connected() + try: + # Check for existing email + if self.db.users.find_one({"email": email}): + return False, "Email already registered" + + # Check for existing username + if self.db.users.find_one({"username": username}): + return False, "Username already taken" + + # Check password strength + password_valid, message = await check_password_strength(password) + if not password_valid: + return False, message + + # Create user document + user_data = { + "email": email, + "username": username, + "334 + Number": team_number, + "password_hash": generate_password_hash(password), + "created_at": datetime.now(timezone.utc), + "last_login": None, + "description": "", + "profile_picture_id": None, + } + + self.db.users.insert_one(user_data) + logger.info(f"Created new user: {username}") + return True, "User created successfully" + + except Exception as e: + logger.error(f"Error creating user: {str(e)}") + return False, "An internal error has occurred." + + @with_mongodb_retry(retries=3, delay=2) + async def authenticate_user(self, login, password): + """Authenticate user with retry mechanism""" + self.ensure_connected() + try: + if user_data := self.db.users.find_one( + {"$or": [{"email": login}, {"username": login}]} + ): + user = User.create_from_db(user_data) + if user and user.check_password(password): + # Update last login + self.db.users.update_one( + {"_id": user._id}, + {"$set": {"last_login": datetime.now(timezone.utc)}}, + ) + logger.info(f"Successful login: {login}") + return True, user + logger.warning(f"Failed login attempt: {login}") + return False, None + except Exception as e: + logger.error(f"Authentication error: {str(e)}") + return False, None + + def get_user_by_id(self, user_id): + """Retrieve user by ID with retry mechanism""" + self.ensure_connected() + try: + from bson.objectid import ObjectId + + user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) + return User.create_from_db(user_data) if user_data else None + except Exception as e: + logger.error(f"Error loading user: {str(e)}") + return None + + @with_mongodb_retry(retries=3, delay=2) + async def update_user_profile(self, user_id, updates): + """Update user profile information""" + self.ensure_connected() + try: + from bson.objectid import ObjectId + + # Filter out None values and empty strings + valid_updates = {k: v for k, v in updates.items() if v is not None and v != ""} + + # Check if username is being updated and is unique + if 'username' in valid_updates: + if existing_user := self.db.users.find_one( + { + "username": valid_updates['username'], + "_id": {"$ne": ObjectId(user_id)}, + } + ): + return False, "Username already taken" + + result = self.db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$set": valid_updates} + ) + + if result.modified_count > 0: + return True, "Profile updated successfully" + return False, "No changes made" + + except Exception as e: + logger.error(f"Error updating profile: {str(e)}") + return False, "An internal error has occurred." + + def get_user_profile(self, username): + """Get user profile by username""" + self.ensure_connected() + try: + user_data = self.db.users.find_one({"username": username}) + return User.create_from_db(user_data) if user_data else None + except Exception as e: + logger.error(f"Error loading profile: {str(e)}") + return None + + @with_mongodb_retry(retries=3, delay=2) + async def update_profile_picture(self, user_id, file_id): + """Update user's profile picture and clean up old one""" + self.ensure_connected() + try: + from bson.objectid import ObjectId + from gridfs import GridFS + + # Get the old profile picture ID first + user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) + old_picture_id = user_data.get('profile_picture_id') if user_data else None + + # Update the profile picture ID + result = self.db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$set": {"profile_picture_id": file_id}} + ) + + # If update was successful and there was an old picture, delete it + if result.modified_count > 0 and old_picture_id: + try: + fs = GridFS(self.db) + if fs.exists(ObjectId(old_picture_id)): + fs.delete(ObjectId(old_picture_id)) + logger.info(f"Deleted old profile picture: {old_picture_id}") + except Exception as e: + logger.error(f"Error deleting old profile picture: {str(e)}") + + return True, "Profile picture updated successfully" + + except Exception as e: + logger.error(f"Error updating profile picture: {str(e)}") + return False, "An internal error has occurred." + + def get_profile_picture(self, user_id): + """Get user's profile picture ID""" + self.ensure_connected() + try: + from bson.objectid import ObjectId + user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) + return user_data.get('profile_picture_id') if user_data else None + except Exception as e: + logger.error(f"Error getting profile picture: {str(e)}") + return None + + @with_mongodb_retry(retries=3, delay=2) + async def delete_user(self, user_id): + """Delete a user account and all associated data""" + self.ensure_connected() + try: + from bson.objectid import ObjectId + + # Get user data first + user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) + if not user_data: + return False, "User not found" + + # Delete profile picture if exists + if user_data.get('profile_picture_id'): + try: + fs = GridFS(self.db) + fs.delete(ObjectId(user_data['profile_picture_id'])) + except Exception as e: + logger.error(f"Error deleting profile picture: {str(e)}") + + # Delete user document + result = self.db.users.delete_one({"_id": ObjectId(user_id)}) + + if result.deleted_count > 0: + return True, "Account deleted successfully" + return False, "Failed to delete account" + + except Exception as e: + logger.error(f"Error deleting user: {str(e)}") + return False, "An internal error has occurred." + + @with_mongodb_retry(retries=3, delay=2) + async def update_user_settings(self, user_id, form_data, profile_picture=None): + """Update user settings including profile picture""" + self.ensure_connected() + try: + updates = {} + + # Handle username update if provided + if new_username := form_data.get('username'): + if new_username != current_user.username: + # Check if username is taken + if self.db.users.find_one({"username": new_username}): + return False + updates['username'] = new_username + + # Handle description update + if description := form_data.get('description'): + updates['description'] = description + + # Handle profile picture + if profile_picture and allowed_file(profile_picture.filename): + from werkzeug.utils import secure_filename + if profile_picture and allowed_file(profile_picture.filename): + fs = GridFS(self.db) + filename = secure_filename(profile_picture.filename) + file_id = fs.put( + profile_picture.stream.read(), + filename=filename, + content_type=profile_picture.content_type + ) + updates['profile_picture_id'] = file_id + + if updates: + success, message = await self.update_user_profile(user_id, updates) + return success + + return True, "Profile updated successfully" + except Exception as e: + logger.error(f"Error updating user settings: {str(e)}") + return False, "An internal error has occurred." \ No newline at end of file diff --git a/app/auth/routes.py b/app/auth/routes.py index 6856cc0..891b173 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -89,11 +89,8 @@ def on_blueprint_init(state): app = state.app mongo = PyMongo(app) - # Use the existing shared connection - user_manager = UserManager( - app.config["MONGO_URI"], - existing_connection=app.db_connection if hasattr(app, 'db_connection') else None - ) + # 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'): @@ -294,11 +291,8 @@ async def check_username(): async def delete_account(): """Delete user account""" try: - # Use the existing shared connection - user_manager = UserManager( - current_app.config["MONGO_URI"], - existing_connection=current_app.db_connection if hasattr(current_app, 'db_connection') else None - ) + # 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()) if success: diff --git a/app/notifications/notification_manager.py b/app/notifications/notification_manager.py index d193a78..7429bfe 100644 --- a/app/notifications/notification_manager.py +++ b/app/notifications/notification_manager.py @@ -14,19 +14,16 @@ class NotificationManager(DatabaseManager): """Manages push notifications and subscriptions""" - def __init__(self, mongo_uri: str, vapid_private_key: str, vapid_claims: Dict[str, str], existing_connection=None): + def __init__(self, mongo_uri: str, vapid_private_key: str, vapid_claims: Dict[str, str]): """Initialize NotificationManager with MongoDB connection and VAPID keys Args: mongo_uri: MongoDB connection URI vapid_private_key: VAPID private key for WebPush vapid_claims: Dictionary containing email and subject for VAPID - existing_connection: Existing MongoDB connection to reuse (optional) """ - # Use existing connection if provided, otherwise get shared connection - if existing_connection is None: - existing_connection = get_database_connection(mongo_uri) - super().__init__(mongo_uri, existing_connection=existing_connection) + # Use the singleton connection + super().__init__(mongo_uri) self.vapid_private_key = vapid_private_key self.vapid_claims = vapid_claims @@ -80,7 +77,7 @@ def _notification_worker(self): @with_mongodb_retry() def _process_pending_notifications(self): """Process all pending notifications that are due to be sent""" - self.ensure_connected() + # Find notifications scheduled for now or earlier that haven't been sent now = datetime.now() @@ -134,7 +131,7 @@ def _process_pending_notifications(self): @with_mongodb_retry() def _schedule_assignment_notifications(self): """Schedule notifications for assignments with due dates""" - self.ensure_connected() + # Get assignments with due dates assignments = self.db.assignments.find({ @@ -302,7 +299,7 @@ async def create_subscription(self, user_id: str, team_number: int, Returns: Tuple[bool, str]: Success status and message """ - self.ensure_connected() + try: # Check if user is in the team @@ -401,7 +398,7 @@ async def delete_subscription(self, user_id: str, team_number: int = None, Returns: Tuple[bool, str]: Success status and message """ - self.ensure_connected() + try: query = {"user_id": user_id} @@ -433,7 +430,7 @@ async def delete_subscription(self, user_id: str, team_number: int = None, # Returns: # Tuple[bool, str]: Success status and message # """ - # self.ensure_connected() + # # try: # # Get the user's subscriptions @@ -474,7 +471,7 @@ async def send_instant_assignment_notification(self, assignment_data: Dict, team assignment_data: The assignment data including title, description, etc. team_number: The team number """ - self.ensure_connected() + try: # Get all subscriptions for assigned users diff --git a/app/notifications/routes.py b/app/notifications/routes.py index 05ee055..a0da1f8 100644 --- a/app/notifications/routes.py +++ b/app/notifications/routes.py @@ -19,12 +19,11 @@ def on_blueprint_init(state): "sub": f"mailto:{app.config.get('VAPID_CLAIM_EMAIL', 'admin@example.com')}" } - # Use the existing shared connection + # Create notification manager with the singleton connection notification_manager = NotificationManager( mongo_uri=app.config["MONGO_URI"], vapid_private_key=vapid_private_key, - vapid_claims=vapid_claims, - existing_connection=app.db_connection if hasattr(app, 'db_connection') else None + vapid_claims=vapid_claims ) # Store in app context for proper cleanup diff --git a/app/scout/TBA.py b/app/scout/TBA.py index 5b99717..5787e9c 100644 --- a/app/scout/TBA.py +++ b/app/scout/TBA.py @@ -7,11 +7,13 @@ logger = logging.getLogger(__name__) class TBAInterface: - def __init__(self): + def __init__(self, api_key=None): self.base_url = "https://www.thebluealliance.com/api/v3" - self.api_key = os.getenv('TBA_AUTH_KEY') + + # First try to use provided api_key, then fall back to environment variable + self.api_key = api_key or os.getenv('TBA_AUTH_KEY') if not self.api_key: - logger.warning("TBA_AUTH_KEY not found in environment variables") + logger.warning("TBA_AUTH_KEY not found in environment variables or provided as parameter") self.headers = { "X-TBA-Auth-Key": self.api_key, diff --git a/app/scout/routes.py b/app/scout/routes.py index f1d9d8b..704a19f 100644 --- a/app/scout/routes.py +++ b/app/scout/routes.py @@ -17,6 +17,7 @@ scouting_bp = Blueprint("scouting", __name__) scouting_manager = None +tba = None logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -24,14 +25,14 @@ @scouting_bp.record def on_blueprint_init(state): - global scouting_manager, limiter + global scouting_manager, tba app = state.app - # Use the existing shared connection - scouting_manager = ScoutingManager( - app.config["MONGO_URI"], - existing_connection=app.db_connection if hasattr(app, 'db_connection') else None - ) + # 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'): diff --git a/app/scout/scouting_utils.py b/app/scout/scouting_utils.py index c509bed..026a9d6 100644 --- a/app/scout/scouting_utils.py +++ b/app/scout/scouting_utils.py @@ -12,11 +12,9 @@ class ScoutingManager(DatabaseManager): - def __init__(self, mongo_uri, existing_connection=None): - # Use existing connection if provided, otherwise get shared connection - if existing_connection is None: - existing_connection = get_database_connection(mongo_uri) - super().__init__(mongo_uri, existing_connection=existing_connection) + def __init__(self, mongo_uri=None): + # Use the singleton connection + super().__init__(mongo_uri) self._ensure_collections() def _ensure_collections(self): @@ -60,7 +58,7 @@ def _create_team_data_collection(self): def add_scouting_data(self, data, scouter_id): """Add new scouting data with retry mechanism""" # Ensure we have a valid connection - self.ensure_connected() + try: # Validate team number diff --git a/app/team/routes.py b/app/team/routes.py index 4dba457..22783da 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -26,11 +26,8 @@ def on_blueprint_init(state): global team_manager app = state.app - # Use the existing shared connection - team_manager = TeamManager( - app.config["MONGO_URI"], - existing_connection=app.db_connection if hasattr(app, 'db_connection') else 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'): diff --git a/app/team/team_utils.py b/app/team/team_utils.py index 12af0e4..b0f1bc7 100644 --- a/app/team/team_utils.py +++ b/app/team/team_utils.py @@ -27,11 +27,9 @@ class TeamManager(DatabaseManager): """Handles all team-related operations""" - def __init__(self, mongo_uri: str, existing_connection=None): - # Use existing connection if provided, otherwise get shared connection - if existing_connection is None: - existing_connection = get_database_connection(mongo_uri) - super().__init__(mongo_uri, existing_connection=existing_connection) + def __init__(self, mongo_uri=None): + # Use the singleton connection + super().__init__(mongo_uri) self._ensure_collections() def _ensure_collections(self) -> None: @@ -60,7 +58,7 @@ async def _get_team(self, query: Dict) -> Optional[Team]: @with_mongodb_retry() def get_team_by_number_sync(self, team_number: int) -> Optional[Team]: """Synchronous version of get_team_by_number""" - self.ensure_connected() + if team_data := self.db.teams.find_one({"team_number": team_number}): return Team.create_from_db(team_data) return None @@ -68,7 +66,7 @@ def get_team_by_number_sync(self, team_number: int) -> Optional[Team]: @with_mongodb_retry() async def get_team_by_number(self, team_number: int) -> Optional[Team]: """Get team by team number""" - self.ensure_connected() + try: if team_number is None: logger.warning("get_team_by_number called with None team_number") @@ -90,7 +88,7 @@ async def create_team(self, team_number: int, creator_id: str, description: Optional[str] = None, logo_id: Optional[str] = None) -> TeamResult: """Create a new team""" - self.ensure_connected() + try: if await self.get_team_by_number(team_number): return False, "Team number already exists" @@ -136,7 +134,7 @@ async def create_team(self, team_number: int, creator_id: str, @with_mongodb_retry(retries=3, delay=2) async def join_team(self, user_id: str, team_join_code: str): """Add a user to a team using the join code""" - self.ensure_connected() + try: team_data = self.db.teams.find_one({"team_join_code": team_join_code}) if not team_data: @@ -171,7 +169,7 @@ async def join_team(self, user_id: str, team_join_code: str): @with_mongodb_retry(retries=3, delay=2) async def leave_team(self, user_id: str, team_number: int): """Remove a user from a team and remove their admin status""" - self.ensure_connected() + try: # Get team to check if user is owner team = await self.get_team_by_number(team_number) @@ -208,7 +206,7 @@ async def leave_team(self, user_id: str, team_number: int): @with_mongodb_retry(retries=3, delay=2) async def get_team_members(self, team_number: int): """Get all members of a team""" - self.ensure_connected() + try: team = self.db.teams.find_one({"team_number": team_number}) if not team: @@ -228,7 +226,7 @@ async def add_admin( self, team_number: int, user_id: str, admin_id: str ) -> Tuple[bool, str]: """Add a new admin to the team""" - self.ensure_connected() + try: # Get the team to check owner status team = await self.get_team_by_number(team_number) @@ -265,7 +263,7 @@ async def remove_admin( self, team_number: int, user_id: str, admin_id: str ) -> Tuple[bool, str]: """Remove an admin from the team""" - self.ensure_connected() + try: # Get the team to check owner status team = await self.get_team_by_number(team_number) @@ -300,7 +298,7 @@ async def remove_admin( @with_mongodb_retry(retries=3, delay=2) async def remove_user(self, team_number: int, user_id: str, admin_id: str): """Remove a user from a team (admin action)""" - self.ensure_connected() + try: team = await self.get_team_by_number(team_number) if not team: @@ -341,7 +339,7 @@ async def remove_user(self, team_number: int, user_id: str, admin_id: str): @with_mongodb_retry(retries=3, delay=2) async def create_or_update_assignment(self, team_number: int, assignment_data: dict, creator_id: str): """Create or update an assignment""" - self.ensure_connected() + try: team = await self.get_team_by_number(team_number) if not team: @@ -408,7 +406,7 @@ def update_assignment_status( self, assignment_id: str, user_id: str, new_status: str ): """Update the status of an assignment""" - self.ensure_connected() + try: assignment = self.db.assignments.find_one({"_id": ObjectId(assignment_id)}) if not assignment: @@ -433,7 +431,7 @@ def update_assignment_status( @with_mongodb_retry(retries=3, delay=2) async def get_team_assignments(self, team_number: int): """Get all assignments for a team""" - self.ensure_connected() + try: assignments = self.db.assignments.find({"team_number": team_number}) return [Assignment.create_from_db(assignment) for assignment in assignments] @@ -447,7 +445,7 @@ async def clear_assignments( ) -> Tuple[bool, str]: """Clear all assignments for a team if user is admin""" try: - self.ensure_connected() + # Get the team to check admin status team = await self.get_team_by_number(team_number) @@ -473,7 +471,7 @@ async def clear_assignments( async 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: - self.ensure_connected() + # Get the team to check owner status team = await self.get_team_by_number(team_number) @@ -520,7 +518,7 @@ async def delete_assignment( ) -> Tuple[bool, str]: """Delete an assignment if user is admin""" try: - self.ensure_connected() + # Get the assignment assignment = self.db.assignments.find_one({"_id": ObjectId(assignment_id)}) @@ -552,7 +550,7 @@ async def update_assignment( self, assignment_id: str, user_id: str, assignment_data: Dict ) -> Tuple[bool, str]: """Update an existing assignment""" - self.ensure_connected() + try: # Get the assignment and team assignment = self.db.assignments.find_one({"_id": ObjectId(assignment_id)}) @@ -592,7 +590,7 @@ async def update_assignment( @with_mongodb_retry(retries=3, delay=2) async def reset_user_team(self, user_id: str): """Reset user's team number to None""" - self.ensure_connected() + try: result = self.db.users.update_one( {"_id": ObjectId(user_id)}, {"$unset": {"teamNumber": ""}} @@ -608,7 +606,7 @@ async def reset_user_team(self, user_id: str): @with_mongodb_retry(retries=3, delay=2) async def validate_user_team(self, user_id: str, team_number: int): """Validate that a user's team exists and update if it doesn't""" - self.ensure_connected() + try: # Get the user's current team team = await self.get_team_by_number(team_number) @@ -742,7 +740,7 @@ def create_default_team_logo(self, team_number: int) -> bytes: @with_mongodb_retry(retries=3, delay=2) async def transfer_ownership(self, team_number: int): """Transfer team ownership to next admin or member""" - self.ensure_connected() + try: team = await self.get_team_by_number(team_number) if not team: @@ -779,7 +777,7 @@ async def transfer_ownership(self, team_number: int): @with_mongodb_retry(retries=3, delay=2) async def get_user_team(self, user_id: str) -> Optional[Team]: """Get the team associated with a user""" - self.ensure_connected() + try: # Find the user to get their team number user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) diff --git a/app/templates/admin/ip_logs.html b/app/templates/admin/ip_logs.html new file mode 100644 index 0000000..9045b60 --- /dev/null +++ b/app/templates/admin/ip_logs.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block content %} +
+

IP Access Logs

+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
IP AddressEndpointStatusUser IDTimestamp
{{ log.ip_address }}{{ log.endpoint }}{{ log.status_code }}{{ log.user_id or 'Anonymous' }}{{ log.datetime }}
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/utils.py b/app/utils.py index 5ccd577..42ced47 100644 --- a/app/utils.py +++ b/app/utils.py @@ -9,7 +9,7 @@ from bson import ObjectId from dotenv import load_dotenv -from flask import flash, jsonify, render_template, request, send_file, g +from flask import flash, jsonify, render_template, request, send_file, g, current_app from flask_limiter import Limiter from flask_limiter.util import get_remote_address from gridfs import GridFS @@ -28,127 +28,126 @@ # ============ Database Utilities ============ -# Global connection pool singleton -_GLOBAL_DB_CONNECTION = { - 'client': None, - 'db': None, - 'last_used': None, - 'connection_timeout': 60, # 1 minutes timeout - 'count': 0 # Reference counter -} - -def get_database_connection(mongo_uri): +class MongoDB: """ - Get or create a global database connection - - Args: - mongo_uri: MongoDB connection URI - - Returns: - dict with client and db objects + Singleton MongoDB connection manager that maintains exactly one connection + throughout the application lifecycle. """ - global _GLOBAL_DB_CONNECTION + # Class variables for the single global connection + _instance = None + _client = None + _db = None + _initialized = False - # Mark this request as having accessed the database - mark_db_accessed() + def __new__(cls, mongo_uri=None, *args, **kwargs): + if cls._instance is None: + cls._instance = super(MongoDB, cls).__new__(cls) + return cls._instance - current_time = time.time() + def __init__(self, mongo_uri=None): + # Only initialize once + if not MongoDB._initialized: + self.mongo_uri = mongo_uri or os.getenv("MONGO_URI") + self._connect() + MongoDB._initialized = True - # If we already have a connection that's in use, return it without incrementing the counter - if (_GLOBAL_DB_CONNECTION['client'] is not None and - _GLOBAL_DB_CONNECTION['last_used'] is not None and - (current_time - _GLOBAL_DB_CONNECTION['last_used']) <= _GLOBAL_DB_CONNECTION['connection_timeout']): - - # Test if the connection is still valid before returning it + def _connect(self): + """Create the MongoDB connection""" try: - _GLOBAL_DB_CONNECTION['client'].admin.command('ismaster') - _GLOBAL_DB_CONNECTION['last_used'] = current_time - logger.debug("Reusing existing MongoDB connection") - - # Only increment the reference counter for new owners - _GLOBAL_DB_CONNECTION['count'] += 1 - logger.info(f"MongoDB connection reference count increased to: {_GLOBAL_DB_CONNECTION['count']}") + logger.info("Creating new MongoDB connection") + self._client = MongoClient( + self.mongo_uri, + serverSelectionTimeoutMS=10000, + maxPoolSize=10, + minPoolSize=1, + connectTimeoutMS=5000, + socketTimeoutMS=30000 + ) - return { - 'client': _GLOBAL_DB_CONNECTION['client'], - 'db': _GLOBAL_DB_CONNECTION['db'] - } + # Test the connection + self._client.server_info() + self._db = self._client.get_default_database() + logger.info("MongoDB connection established successfully") except Exception as e: - logger.warning(f"Existing connection test failed: {str(e)}. Will create new connection.") + logger.error(f"Failed to connect to MongoDB: {str(e)}") + raise - # If we get here, we need to create a new connection - # Close the existing connection if there is one - if _GLOBAL_DB_CONNECTION['client'] is not None: - try: - _GLOBAL_DB_CONNECTION['client'].close() - logger.info("Closed stale MongoDB connection") - except Exception as e: - logger.warning(f"Error closing MongoDB connection: {str(e)}") + def get_client(self): + """Get the MongoDB client""" + if self._client is None: + self._connect() + return self._client - # Create a new connection + def get_db(self): + """Get the MongoDB database""" + if self._db is None: + self._connect() + return self._db + + def close(self): + """Close the MongoDB connection during application shutdown""" + if self._client is not None: + try: + self._client.close() + logger.info("Closed MongoDB connection") + except Exception as e: + logger.warning(f"Error closing MongoDB connection: {str(e)}") + finally: + self._client = None + self._db = None + MongoDB._initialized = False + +# Global instance +_mongodb_instance = None + +def get_mongodb_instance(mongo_uri=None): + """Get the singleton MongoDB instance""" + global _mongodb_instance + if _mongodb_instance is None: + _mongodb_instance = MongoDB(mongo_uri) + # 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: - client = MongoClient( - mongo_uri, - serverSelectionTimeoutMS=10000, - maxPoolSize=10, - minPoolSize=1, - connectTimeoutMS=5000, - socketTimeoutMS=30000, - waitQueueTimeoutMS=10000 - ) - # Test the connection - client.server_info() - db = client.get_default_database() - - _GLOBAL_DB_CONNECTION['client'] = client - _GLOBAL_DB_CONNECTION['db'] = db - _GLOBAL_DB_CONNECTION['last_used'] = current_time - _GLOBAL_DB_CONNECTION['count'] = 1 # Start with 1 since we're returning it - - logger.info("Created new MongoDB connection") - logger.info(f"MongoDB connection reference count increased to: 1") - - return { - 'client': _GLOBAL_DB_CONNECTION['client'], - 'db': _GLOBAL_DB_CONNECTION['db'] - } - except Exception as e: - logger.error(f"Failed to connect to MongoDB: {str(e)}") - raise + if hasattr(g, '_get_current_object'): + g.db_accessed = True + except (RuntimeError, ImportError): + # Not in a Flask context + pass -def release_connection(): - """ - Release a reference to the global connection. - When count reaches 0, the connection remains but is marked as unused. - """ - global _GLOBAL_DB_CONNECTION - - if _GLOBAL_DB_CONNECTION['count'] > 0: - _GLOBAL_DB_CONNECTION['count'] -= 1 - logger.info(f"MongoDB connection reference count decreased to: {_GLOBAL_DB_CONNECTION['count']}") +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: + """Base database manager that uses the singleton MongoDB connection""" - # Update last_used time - _GLOBAL_DB_CONNECTION['last_used'] = time.time() + def __init__(self, mongo_uri=None): + """Initialize the database manager with the global singleton connection""" + mongodb = get_mongodb_instance(mongo_uri) + 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(): - """ - Force close the global connection regardless of reference count. - Should only be used during application shutdown. - """ - global _GLOBAL_DB_CONNECTION - - try: - if _GLOBAL_DB_CONNECTION['client'] is not None: - _GLOBAL_DB_CONNECTION['client'].close() - logger.info("Forced close of MongoDB connection") - except Exception as e: - logger.error(f"Error force closing MongoDB connection: {str(e)}") - - # Reset all connection values - _GLOBAL_DB_CONNECTION['client'] = None - _GLOBAL_DB_CONNECTION['db'] = None - _GLOBAL_DB_CONNECTION['count'] = 0 - _GLOBAL_DB_CONNECTION['last_used'] = None + """Legacy function for backward compatibility""" + close_mongodb_connection() def with_mongodb_retry(retries=3, delay=2): """Decorator for retrying MongoDB operations""" @@ -170,83 +169,9 @@ def wrapper(*args, **kwargs): return wrapper return decorator -class DatabaseManager: - """Base class for database operations""" - def __init__(self, mongo_uri: str, existing_connection=None): - self.mongo_uri = mongo_uri - self.client = None - self.db = None - self.owns_connection = False - - # Use existing connection if provided, otherwise get from global pool - if (existing_connection and - existing_connection.get('client') is not None and - existing_connection.get('db') is not None): - # We're reusing an existing connection, don't increment the counter - self.client = existing_connection['client'] - self.db = existing_connection['db'] - logger.info("Using existing MongoDB connection") - # This instance doesn't "own" the connection (won't decrement on close) - self.owns_connection = False - else: - # Get a new connection from the pool, counter already incremented - conn = get_database_connection(self.mongo_uri) - self.client = conn['client'] - self.db = conn['db'] - # This instance "owns" a reference and will decrement on close - self.owns_connection = True - logger.info("Using global MongoDB connection") - - def connect(self): - """Ensure connection to MongoDB""" - conn = get_database_connection(self.mongo_uri) - self.client = conn['client'] - self.db = conn['db'] - - def ensure_connected(self): - """Ensure database connection is active""" - try: - # Mark this request as having accessed the database - mark_db_accessed() - - # Check if client is None first - if self.client is None or self.db is None: - logger.warning("No active MongoDB connection. Connecting...") - self.connect() - return - - # Test if connection is still alive with a lightweight command - self.client.admin.command('ismaster') - except Exception as e: - logger.warning(f"Lost connection to MongoDB: {str(e)}. Attempting to reconnect...") - self.connect() - - def close(self): - """Release the MongoDB connection reference""" - if self.owns_connection: - release_connection() - self.client = None - self.db = None - logger.info("Released MongoDB connection reference") - - def get_connection(self): - """Return the current connection for reuse""" - self.ensure_connected() - return {'client': self.client, 'db': self.db} - - def __del__(self): - """Cleanup MongoDB connection reference on object deletion""" - with contextlib.suppress(ImportError, AttributeError, TypeError): - self.close() - def mark_db_accessed(): """Mark the current request as having accessed the database""" - try: - if hasattr(g, '_get_current_object'): - g.db_accessed = True - except (RuntimeError, ImportError): - # Handle case when not in a Flask context - pass + _mark_db_accessed() # ============ Route Utilities ============ diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..33525d6 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +from app.app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run() \ No newline at end of file