Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ __pycache__/
.env
.envrc
.DS_Store
ca-certificate.crt
ca-certificate.crt
firebase-service-account-key.json
58 changes: 51 additions & 7 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
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, jsonify, 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
from src.scrapers.games_scraper import fetch_game_schedule
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, client

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():
Expand Down Expand Up @@ -73,13 +97,22 @@ 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():
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(
Expand Down Expand Up @@ -136,6 +169,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...")
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
Flask
Flask-CORS
Flask-JWT-Extended==4.7.1
Flask-GraphQL
graphene
pymongo
Expand Down
3 changes: 3 additions & 0 deletions src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
8 changes: 7 additions & 1 deletion src/mutations/__init__.py
Original file line number Diff line number Diff line change
@@ -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
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
25 changes: 25 additions & 0 deletions src/mutations/add_favorite_game.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 additions & 0 deletions src/mutations/login_user.py
Original file line number Diff line number Diff line change
@@ -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),
)
19 changes: 19 additions & 0 deletions src/mutations/logout_user.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions src/mutations/refresh_access_token.py
Original file line number Diff line number Diff line change
@@ -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),
)
21 changes: 21 additions & 0 deletions src/mutations/remove_favorite_game.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions src/mutations/signup_user.py
Original file line number Diff line number Diff line change
@@ -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),
)
15 changes: 15 additions & 0 deletions src/queries/game_query.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down
11 changes: 11 additions & 0 deletions src/repositories/game_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
40 changes: 38 additions & 2 deletions src/schema.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
3 changes: 2 additions & 1 deletion src/scrapers/game_details_scrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."}]
Expand All @@ -154,7 +155,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" or team == "CORNELL":
cornell_score += 1
else:
opp_score += 1
Expand Down
Loading
Loading