From 583c1f9ec732e7bf8590b07927edcbe99fef9451 Mon Sep 17 00:00:00 2001 From: AnikDey-exe Date: Fri, 30 Jan 2026 14:46:31 -0500 Subject: [PATCH 1/5] fixed field hockey issue with opp getting all the scores --- src/scrapers/game_details_scrape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scrapers/game_details_scrape.py b/src/scrapers/game_details_scrape.py index 5b6fc6d..0041b54 100644 --- a/src/scrapers/game_details_scrape.py +++ b/src/scrapers/game_details_scrape.py @@ -154,7 +154,7 @@ def field_hockey_summary(box_score_section): event = row.find_all(TAG_TD)[2] desc = event.find_all(TAG_SPAN)[-1].text.strip() - if team == "COR" or team == "CU": + if team == "COR" or team == "CU" or team == "CORFH": cornell_score += 1 else: opp_score += 1 From 6292c0f0179897d19247f13f5419ea7608e3fa85 Mon Sep 17 00:00:00 2001 From: AnikDey-exe Date: Fri, 30 Jan 2026 14:58:04 -0500 Subject: [PATCH 2/5] fixed even more --- src/scrapers/game_details_scrape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scrapers/game_details_scrape.py b/src/scrapers/game_details_scrape.py index 0041b54..0b13081 100644 --- a/src/scrapers/game_details_scrape.py +++ b/src/scrapers/game_details_scrape.py @@ -154,7 +154,7 @@ def field_hockey_summary(box_score_section): event = row.find_all(TAG_TD)[2] desc = event.find_all(TAG_SPAN)[-1].text.strip() - if team == "COR" or team == "CU" or team == "CORFH": + if team == "COR" or team == "CU" or team == "CORFH" or team == "CORNELL": cornell_score += 1 else: opp_score += 1 From 68144307c83e6c0c49c22c28a36a93ae303f2a91 Mon Sep 17 00:00:00 2001 From: AnikDey-exe Date: Thu, 12 Feb 2026 17:50:24 -0500 Subject: [PATCH 3/5] hockey game desc field should not be null now --- src/scrapers/game_details_scrape.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scrapers/game_details_scrape.py b/src/scrapers/game_details_scrape.py index 5b6fc6d..757744c 100644 --- a/src/scrapers/game_details_scrape.py +++ b/src/scrapers/game_details_scrape.py @@ -137,6 +137,7 @@ def hockey_summary(box_score_section): 'assist': assist, 'cor_score': cornell_score, 'opp_score': opp_score, + 'description': f"Scored by {scorer}. Assisted by {assist}." }) if not summary: summary = [{"message": "No scoring events in this game."}] From b77a24f1b21a1e9465e463a5213e41aa7419593d Mon Sep 17 00:00:00 2001 From: claiireyu Date: Mon, 16 Feb 2026 13:45:37 -0500 Subject: [PATCH 4/5] Add JWT authentication and user management features - Implemented JWT authentication with token creation and revocation. - Added user login, signup, and logout mutations. - Introduced favorite game management with add/remove functionality. - Updated database schema to support token blocklist and user favorite games. - Enhanced game query to retrieve user's favorited games. - Configured CORS and JWT settings in the Flask app. - Added necessary dependencies in requirements.txt. --- app.py | 49 +++++++++++++++++++++++---- requirements.txt | 2 ++ src/database.py | 3 ++ src/mutations/__init__.py | 8 ++++- src/mutations/add_favorite_game.py | 25 ++++++++++++++ src/mutations/login_user.py | 23 +++++++++++++ src/mutations/logout_user.py | 19 +++++++++++ src/mutations/refresh_access_token.py | 14 ++++++++ src/mutations/remove_favorite_game.py | 21 ++++++++++++ src/mutations/signup_user.py | 33 ++++++++++++++++++ src/queries/game_query.py | 15 ++++++++ src/repositories/game_repository.py | 11 ++++++ src/schema.py | 40 ++++++++++++++++++++-- src/services/game_service.py | 7 ++++ src/utils/constants.py | 5 +++ 15 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/mutations/add_favorite_game.py create mode 100644 src/mutations/login_user.py create mode 100644 src/mutations/logout_user.py create mode 100644 src/mutations/refresh_access_token.py create mode 100644 src/mutations/remove_favorite_game.py create mode 100644 src/mutations/signup_user.py diff --git a/app.py b/app.py index 67aec02..9720900 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,17 @@ import logging import argparse -from flask import Flask, request, g +import signal +import sys import time +from datetime import datetime, timedelta + +from dotenv import load_dotenv + +load_dotenv() + +from flask import Flask, request, g +from flask_cors import CORS +from flask_jwt_extended import JWTManager from flask_graphql import GraphQLView from graphene import Schema from src.schema import Query, Mutation @@ -9,15 +19,29 @@ from src.scrapers.youtube_stats import fetch_videos from src.scrapers.daily_sun_scrape import fetch_news from src.services.article_service import ArticleService +from src.utils.constants import JWT_SECRET_KEY from src.utils.team_loader import TeamLoader -import signal -import sys -from dotenv import load_dotenv - -load_dotenv() +from src.database import db app = Flask(__name__) +# CORS: allow frontend (different origin) to call this API +CORS(app, supports_credentials=True) + +# JWT config +app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) +app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30) + +jwt = JWTManager(app) + + +@jwt.token_in_blocklist_loader +def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: + """Reject the request if the token's jti is in the blocklist (e.g. after logout).""" + jti = jwt_payload["jti"] + return db["token_blocklist"].find_one({"jti": jti}) is not None + @app.before_request def start_timer(): @@ -73,7 +97,7 @@ def log_response_time(response): datefmt="%Y-%m-%d %H:%M:%S", ) -schema = Schema(query=Query, mutation=Mutation) +schema = Schema(query=Query, mutation=Mutation, auto_camelcase=True) def create_context(): @@ -136,6 +160,17 @@ class DefaultArgs: scheduler.init_app(app) scheduler.start() + @scheduler.task("interval", id="cleanse_token_blocklist", seconds=86400) # 24 hours + def cleanse_token_blocklist(): + """Remove expired tokens from blocklist so the collection doesn't grow forever.""" + from datetime import timezone + from src.database import db + result = db["token_blocklist"].delete_many( + {"expires_at": {"$lt": datetime.now(timezone.utc)}} + ) + if result.deleted_count: + logging.info(f"Cleansed {result.deleted_count} expired token(s) from blocklist") + @scheduler.task("interval", id="scrape_schedules", seconds=43200) # 12 hours def scrape_schedules(): logging.info("Scraping game schedules...") diff --git a/requirements.txt b/requirements.txt index ad860f8..6ef629f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ Flask +Flask-CORS +Flask-JWT-Extended==4.7.1 Flask-GraphQL graphene pymongo diff --git a/src/database.py b/src/database.py index c4b7059..20f1331 100644 --- a/src/database.py +++ b/src/database.py @@ -98,6 +98,9 @@ def setup_database_indexes(): background=True ) + # JWT blocklist: fast lookup by jti + db["token_blocklist"].create_index([("jti", 1)], background=True) + print("✅ MongoDB indexes created successfully") except Exception as e: print(f"❌ Failed to create MongoDB indexes: {e}") diff --git a/src/mutations/__init__.py b/src/mutations/__init__.py index 3df8e4d..89169f5 100644 --- a/src/mutations/__init__.py +++ b/src/mutations/__init__.py @@ -1,4 +1,10 @@ from .create_game import CreateGame from .create_team import CreateTeam from .create_youtube_video import CreateYoutubeVideo -from .create_article import CreateArticle \ No newline at end of file +from .create_article import CreateArticle +from .login_user import LoginUser +from .signup_user import SignupUser +from .refresh_access_token import RefreshAccessToken +from .logout_user import LogoutUser +from .add_favorite_game import AddFavoriteGame +from .remove_favorite_game import RemoveFavoriteGame \ No newline at end of file diff --git a/src/mutations/add_favorite_game.py b/src/mutations/add_favorite_game.py new file mode 100644 index 0000000..7499d4a --- /dev/null +++ b/src/mutations/add_favorite_game.py @@ -0,0 +1,25 @@ +from bson import ObjectId +from graphql import GraphQLError +from graphene import Mutation, String, Boolean + +from flask_jwt_extended import get_jwt_identity, jwt_required +from src.database import db +from src.services.game_service import GameService + + +class AddFavoriteGame(Mutation): + class Arguments: + game_id = String(required=True, description="ID of the game to add to favorites.") + + success = Boolean() + + @jwt_required() + def mutate(self, info, game_id): + if not GameService.get_game_by_id(game_id): + raise GraphQLError("Game not found.") + user_id = get_jwt_identity() + db["users"].update_one( + {"_id": ObjectId(user_id)}, + {"$addToSet": {"favorite_game_ids": game_id}}, + ) + return AddFavoriteGame(success=True) diff --git a/src/mutations/login_user.py b/src/mutations/login_user.py new file mode 100644 index 0000000..b606738 --- /dev/null +++ b/src/mutations/login_user.py @@ -0,0 +1,23 @@ +from graphql import GraphQLError +from graphene import Mutation, String, Field + +from flask_jwt_extended import create_access_token, create_refresh_token +from src.database import db + + +class LoginUser(Mutation): + class Arguments: + net_id = String(required=True, description="User's net ID (e.g. Cornell netid).") + + access_token = String() + refresh_token = String() + + def mutate(self, info, net_id): + user = db["users"].find_one({"net_id": net_id}) + if not user: + raise GraphQLError("User not found.") + identity = str(user["_id"]) + return LoginUser( + access_token=create_access_token(identity=identity), + refresh_token=create_refresh_token(identity=identity), + ) diff --git a/src/mutations/logout_user.py b/src/mutations/logout_user.py new file mode 100644 index 0000000..05020da --- /dev/null +++ b/src/mutations/logout_user.py @@ -0,0 +1,19 @@ +from datetime import datetime, timezone + +from graphene import Mutation, Boolean + +from flask_jwt_extended import get_jwt, jwt_required +from src.database import db + + +class LogoutUser(Mutation): + success = Boolean() + + @jwt_required(verify_type=False) + def mutate(self, info): + token = get_jwt() + jti = token["jti"] + exp = token["exp"] + expires_at = datetime.fromtimestamp(exp, tz=timezone.utc) + db["token_blocklist"].insert_one({"jti": jti, "expires_at": expires_at}) + return LogoutUser(success=True) diff --git a/src/mutations/refresh_access_token.py b/src/mutations/refresh_access_token.py new file mode 100644 index 0000000..1e26e48 --- /dev/null +++ b/src/mutations/refresh_access_token.py @@ -0,0 +1,14 @@ +from graphene import Mutation, String + +from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required + + +class RefreshAccessToken(Mutation): + new_access_token = String() + + @jwt_required(refresh=True) + def mutate(self, info): + identity = get_jwt_identity() + return RefreshAccessToken( + new_access_token=create_access_token(identity=identity), + ) diff --git a/src/mutations/remove_favorite_game.py b/src/mutations/remove_favorite_game.py new file mode 100644 index 0000000..e5c2c1c --- /dev/null +++ b/src/mutations/remove_favorite_game.py @@ -0,0 +1,21 @@ +from bson import ObjectId +from graphene import Mutation, String, Boolean + +from flask_jwt_extended import get_jwt_identity, jwt_required +from src.database import db + + +class RemoveFavoriteGame(Mutation): + class Arguments: + game_id = String(required=True, description="ID of the game to remove from favorites.") + + success = Boolean() + + @jwt_required() + def mutate(self, info, game_id): + user_id = get_jwt_identity() + db["users"].update_one( + {"_id": ObjectId(user_id)}, + {"$pull": {"favorite_game_ids": game_id}}, + ) + return RemoveFavoriteGame(success=True) diff --git a/src/mutations/signup_user.py b/src/mutations/signup_user.py new file mode 100644 index 0000000..eb6f6ae --- /dev/null +++ b/src/mutations/signup_user.py @@ -0,0 +1,33 @@ +from graphql import GraphQLError +from graphene import Mutation, String + +from flask_jwt_extended import create_access_token, create_refresh_token +from src.database import db + + +class SignupUser(Mutation): + class Arguments: + net_id = String(required=True, description="User's net ID (e.g. Cornell netid).") + name = String(required=False, description="Display name.") + email = String(required=False, description="Email address.") + + access_token = String() + refresh_token = String() + + def mutate(self, info, net_id, name=None, email=None): + if db["users"].find_one({"net_id": net_id}): + raise GraphQLError("Net ID already exists.") + user_doc = { + "net_id": net_id, + "favorite_game_ids": [], + } + if name is not None: + user_doc["name"] = name + if email is not None: + user_doc["email"] = email + result = db["users"].insert_one(user_doc) + identity = str(result.inserted_id) + return SignupUser( + access_token=create_access_token(identity=identity), + refresh_token=create_refresh_token(identity=identity), + ) diff --git a/src/queries/game_query.py b/src/queries/game_query.py index 163edf8..5842d05 100644 --- a/src/queries/game_query.py +++ b/src/queries/game_query.py @@ -1,4 +1,7 @@ +from bson import ObjectId +from flask_jwt_extended import get_jwt_identity, jwt_required from graphene import ObjectType, String, Field, List, Int, DateTime +from src.database import db from src.services.game_service import GameService from src.types import GameType @@ -27,6 +30,18 @@ class GameQuery(ObjectType): GameType, sport=String(required=True), gender=String(required=True) ) games_by_date = List(GameType, startDate=DateTime(required=True), endDate=DateTime(required=True)) + my_favorited_games = List(GameType, description="Current user's favorited games (requires auth).") + + @jwt_required() + def resolve_my_favorited_games(self, info): + user_id = get_jwt_identity() + user = db["users"].find_one({"_id": ObjectId(user_id)}) + if not user: + return [] + favorite_ids = user.get("favorite_game_ids") or [] + if not favorite_ids: + return [] + return GameService.get_games_by_ids(favorite_ids) def resolve_games(self, info, limit=100, offset=0): """ diff --git a/src/repositories/game_repository.py b/src/repositories/game_repository.py index 95e679b..e531286 100644 --- a/src/repositories/game_repository.py +++ b/src/repositories/game_repository.py @@ -247,6 +247,17 @@ def find_by_date(startDate, endDate): games = game_collection.find(query) return [Game.from_dict(game) for game in games] + @staticmethod + def find_by_ids(game_ids): + """ + Fetch games from the MongoDB collection by a list of IDs. + """ + if not game_ids: + return [] + game_collection = db["game"] + cursor = game_collection.find({"_id": {"$in": game_ids}}) + return [Game.from_dict(g) for g in cursor] + @staticmethod def delete_games_by_ids(game_ids): """ diff --git a/src/schema.py b/src/schema.py index 0f3ae99..70b5473 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,5 +1,24 @@ +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt, + get_jwt_identity, + jwt_required, +) from graphene import ObjectType, Schema, Mutation -from src.mutations import CreateGame, CreateTeam, CreateYoutubeVideo, CreateArticle +from src.database import db +from src.mutations import ( + CreateGame, + CreateTeam, + CreateYoutubeVideo, + CreateArticle, + LoginUser, + SignupUser, + RefreshAccessToken, + LogoutUser, + AddFavoriteGame, + RemoveFavoriteGame, +) from src.queries import GameQuery, TeamQuery, YoutubeVideoQuery, ArticleQuery @@ -12,6 +31,23 @@ class Mutation(ObjectType): create_team = CreateTeam.Field(description="Creates a new team.") create_youtube_video = CreateYoutubeVideo.Field(description="Creates a new youtube video.") create_article = CreateArticle.Field(description="Creates a new article.") + login_user = LoginUser.Field(description="Login by net_id; returns access_token and refresh_token.") + signup_user = SignupUser.Field( + description="Create a new user by net_id; returns access_token and refresh_token (no separate login needed).", + ) + refresh_access_token = RefreshAccessToken.Field( + description="Exchange a valid refresh token (in Authorization header) for a new access_token.", + ) + logout_user = LogoutUser.Field( + description="Revoke the current token (access or refresh). Send token in Authorization header.", + ) + add_favorite_game = AddFavoriteGame.Field( + description="Add a game to the current user's favorites (requires auth).", + ) + remove_favorite_game = RemoveFavoriteGame.Field( + description="Remove a game from the current user's favorites (requires auth).", + ) -schema = Schema(query=Query, mutation=Mutation) +# auto_camelcase=True (default): GraphQL API uses camelCase (loginUser, accessToken, refreshToken, etc.) +schema = Schema(query=Query, mutation=Mutation, auto_camelcase=True) diff --git a/src/services/game_service.py b/src/services/game_service.py index c0ae3db..6fd3479 100644 --- a/src/services/game_service.py +++ b/src/services/game_service.py @@ -27,6 +27,13 @@ def get_game_by_id(game_id): """ return GameRepository.find_by_id(game_id) + @staticmethod + def get_games_by_ids(game_ids): + """ + Retrieve games by a list of IDs. Returns only games that exist; order not guaranteed. + """ + return GameRepository.find_by_ids(game_ids) + @staticmethod def create_game(data): """ diff --git a/src/utils/constants.py b/src/utils/constants.py index 81f0414..38c2ae7 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1,3 +1,8 @@ +import os + +# JWT +JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] + # Prefix for opponent image URLs IMAGE_PREFIX = "https://dxbhsrqyrr690.cloudfront.net/sidearm.nextgen.sites/cornellbigred.com" From c9be2efa34670fad2a6d89ba28f8bddfb5633d41 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 13:41:51 -0500 Subject: [PATCH 5/5] Add health endpoint --- .gitignore | 3 ++- app.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 7e14a5b..76f90e5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ __pycache__/ .env .envrc .DS_Store -ca-certificate.crt \ No newline at end of file +ca-certificate.crt +firebase-service-account-key.json \ No newline at end of file diff --git a/app.py b/app.py index 9720900..dfc06e8 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ load_dotenv() -from flask import Flask, request, g +from flask import Flask, jsonify, request, g from flask_cors import CORS from flask_jwt_extended import JWTManager from flask_graphql import GraphQLView @@ -21,7 +21,7 @@ from src.services.article_service import ArticleService from src.utils.constants import JWT_SECRET_KEY from src.utils.team_loader import TeamLoader -from src.database import db +from src.database import db, client app = Flask(__name__) @@ -104,6 +104,15 @@ def create_context(): return {"team_loader": TeamLoader()} +@app.route("/health") +def health_check(): + try: + client.admin.command("ping") + return jsonify({"status": "healthy", "database": "connected"}), 200 + except Exception: + return jsonify({"status": "unhealthy", "database": "disconnected"}), 503 + + app.add_url_rule( "/graphql", view_func=GraphQLView.as_view(