Skip to content
Open
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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ pytz
gunicorn
flask-socketio
firebase-admin==7.3.0
gspread
pytest
simple-websocket
5 changes: 5 additions & 0 deletions src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ def setup_database_indexes():
# Account deletion revokes all tokens for the deleted user.
db["token_blocklist"].create_index([("user_id", 1)], background=True)

# Reports are read newest-first and can optionally be associated with a
# sport. Reports are anonymous by design.
db["reports"].create_index([("created_at", -1)], background=True)
db["reports"].create_index([("sport", 1)], background=True)

# One application user may be linked to only one Firebase account.
try:
db["users"].create_index(
Expand Down
1 change: 1 addition & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .youtube_video import YoutubeVideo
from .article import Article
from .user import User
from .report import Report
44 changes: 44 additions & 0 deletions src/models/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import datetime, timezone

from bson.objectid import ObjectId


class Report:
"""An anonymous bug or data-quality report."""

def __init__(
self,
issue,
sport=None,
description=None,
id=None,
created_at=None,
):
self.id = str(id) if id is not None else str(ObjectId())
self.issue = issue
self.sport = sport
self.description = description
self.created_at = created_at or datetime.now(timezone.utc)

def to_dict(self):
"""Convert the report to a MongoDB document."""
return {
"_id": self.id,
"issue": self.issue,
"sport": self.sport,
"description": self.description,
"created_at": self.created_at,
}

@classmethod
def from_dict(cls, data):
"""Convert a MongoDB document to a report."""
if not data:
return None
return cls(
id=data.get("_id"),
issue=data.get("issue"),
sport=data.get("sport"),
description=data.get("description"),
created_at=data.get("created_at"),
)
1 change: 1 addition & 0 deletions src/mutations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
from .refresh_access_token import RefreshAccessToken
from .logout_user import LogoutUser
from .delete_user import DeleteUser
from .report import CreateReport, DeleteReport
from .favorite_team_mutations import AddFavoriteTeam, RemoveFavoriteTeam
from .bookmarked_highlight_mutations import AddBookmarkedHighlight, RemoveBookmarkedHighlight
31 changes: 31 additions & 0 deletions src/mutations/report.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Structurally, this looks good Claire, but how are we handling authentication in this case? As of now, this file permits anyone to delete any report, which is something we wouldn't want. Look into including an admin check on deleteReport

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from graphene import Field, Mutation, String

from src.services.report_service import ReportService
from src.types import ReportType


class CreateReport(Mutation):
class Arguments:
issue = String(required=True)
sport = String(required=False)
description = String(required=False)

report = Field(ReportType, required=True)

def mutate(self, info, issue, sport=None, description=None):
report = ReportService.create_report(
issue=issue,
sport=sport,
description=description,
)
return CreateReport(report=report)


class DeleteReport(Mutation):
class Arguments:
report_id = String(required=True)

report = Field(ReportType, required=True)

def mutate(self, info, report_id):
return DeleteReport(report=ReportService.delete_report(report_id))
1 change: 1 addition & 0 deletions src/queries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .youtube_video_query import YoutubeVideoQuery
from .article_query import ArticleQuery
from .user_query import UserQuery
from .report_query import ReportQuery
2 changes: 1 addition & 1 deletion src/queries/game_query.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from flask_jwt_extended import get_jwt_identity
from graphene import Boolean, ObjectType, String, Field, List, Int, DateTime
from src.services.game_service import GameService
from src.types import GameType

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause aNameError at runtime. Same problem as user_repository.py: this merge deleted from flask_jwt_extended import get_jwt_identity from line 1, but it's still called at line 41 inside the @graphql_jwt_required() resolver.

Also note this import added a second from src.types import GameType on this line while line 5 already imports it alongside FavoriteTeamType. Drop the duplicate and put the flask_jwt_extended import back.

from src.services.user_service import UserService
from src.types import FavoriteTeamType, GameType
from src.utils.graphql_errors import graphql_jwt_required
Expand Down
27 changes: 27 additions & 0 deletions src/queries/report_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from graphene import Int, List, ObjectType

from src.services.report_service import ReportService
from src.types import ReportType


class ReportQuery(ObjectType):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a related note to this comment, this query essentially allows anyone to read any report — which would again be something we wouldn't want. Look into putting an admin check/auth on this query too

I think Uplift had a similar reports problem and is about to put out a PR to implement this solution. Consider basing your work off of theirs if their PR comes out before you get to this

"""Read operations for anonymous Score reports."""

reports = List(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does reports differ from get_all_reports? From their description alone, they seem like they're the same field, and so you should get rid of one.

ReportType,
limit=Int(default_value=100),
offset=Int(default_value=0),
description="Get submitted reports with pagination.",
)
get_all_reports = List(
ReportType,
limit=Int(default_value=100),
offset=Int(default_value=0),
description="Get submitted reports with pagination.",
)

def resolve_reports(self, info, limit=100, offset=0):
return ReportService.get_reports(limit=limit, offset=offset)

def resolve_get_all_reports(self, info, limit=100, offset=0):
return ReportService.get_reports(limit=limit, offset=offset)
1 change: 1 addition & 0 deletions src/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .youtube_video_repository import YoutubeVideoRepository
from .article_repository import ArticleRepository
from .user_repository import UserRepository
from .report_repository import ReportRepository
32 changes: 32 additions & 0 deletions src/repositories/report_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from src.database import db
from src.models.report import Report


class ReportRepository:
"""Persistence operations for user reports."""

collection = db["reports"]

@classmethod
def insert(cls, report):
cls.collection.insert_one(report.to_dict())
return report

@classmethod
def find_all(cls, limit=100, offset=0):
reports = (
cls.collection.find()
.sort("created_at", -1)
.skip(offset)
.limit(limit)
)
return [Report.from_dict(report) for report in reports]

@classmethod
def find_by_id(cls, report_id):
return Report.from_dict(cls.collection.find_one({"_id": report_id}))

@classmethod
def delete_by_id(cls, report_id):
result = cls.collection.delete_one({"_id": report_id})
return result.deleted_count > 0
1 change: 0 additions & 1 deletion src/repositories/user_repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from bson import ObjectId
from pymongo import ReturnDocument

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last master merge dropped two imports whose call sites are still there, so parts of the app raise NameError at runtime. Andrew's comment about the schema.py conflict wheresrc/schema.py now imports AddFavoriteTeam/RemoveFavoriteTeam/AddBookmarkedHighlight/RemoveBookmarkedHighlight from src.queries, which doesn't export them. That one is an ImportError at startup, so the app won't boot at all).


from src.database import db
from src.models.user import User
Expand Down
27 changes: 25 additions & 2 deletions src/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,32 @@
RefreshAccessToken,
LogoutUser,
DeleteUser,
CreateReport,
DeleteReport,
)
from src.queries import (
ArticleQuery,
GameQuery,
ReportQuery,
TeamQuery,
UserQuery,
YoutubeVideoQuery,
AddFavoriteTeam,
RemoveFavoriteTeam,
AddBookmarkedHighlight,
RemoveBookmarkedHighlight,
)
from src.queries import GameQuery, TeamQuery, YoutubeVideoQuery, ArticleQuery, UserQuery


class Query(UserQuery, TeamQuery, GameQuery, YoutubeVideoQuery, ArticleQuery, ObjectType):
class Query(
UserQuery,
TeamQuery,
GameQuery,
YoutubeVideoQuery,
ArticleQuery,
ReportQuery,
ObjectType,
):
pass


Expand All @@ -41,6 +58,12 @@ class Mutation(ObjectType):
delete_user = DeleteUser.Field(
description="Permanently delete the current user's application and Firebase accounts (requires auth).",
)
create_report = CreateReport.Field(
description="Submit a bug or data-quality report.",
)
delete_report = DeleteReport.Field(
description="Delete a submitted report.",
)
add_favorite_team = AddFavoriteTeam.Field(
description="Add a team, identified by gender and sport, to the current user's favorites (requires auth).",
)
Expand Down
1 change: 1 addition & 0 deletions src/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .youtube_video_service import YoutubeVideoService
from .article_service import ArticleService
from .user_service import UserService
from .report_service import ReportService
113 changes: 113 additions & 0 deletions src/services/report_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import logging
import os

from graphql import GraphQLError

from src.models.report import Report
from src.repositories.report_repository import ReportRepository


REPORT_ISSUES = frozenset(
{
"INACCURATE_SCORES",
"WRONG_GAME_TIME",
"MISSING_GAME",
"OTHER",
}
)


class ReportService:
"""Business logic for creating and managing anonymous reports."""

@staticmethod
def create_report(issue, sport=None, description=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since createReport doesn't require auth, anyone could spam it and fill up the collection and our Sheets API quota. Look into adding a rate limit, or auth if the client supports it.

issue = (issue or "").strip().upper()
sport = sport.strip() if sport else None
description = description.strip() if description else None

if issue not in REPORT_ISSUES:
raise GraphQLError("Issue is not a valid report category.")
if description and len(description) > 5000:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Can we make 5000 a constant in utils and then reference it here? Emphasis is reusability here; a limit of 5000 characters could be relevant beyond just this context

raise GraphQLError("Report description is too long.")

report = Report(
issue=issue,
sport=sport,
description=description,
)
ReportRepository.insert(report)
ReportService._mirror_to_google_sheet(report)
return report

@staticmethod
def get_reports(limit=100, offset=0):
limit = max(1, min(limit or 100, 100))
offset = max(offset or 0, 0)
return ReportRepository.find_all(limit=limit, offset=offset)

@staticmethod
def delete_report(report_id):
report_id = str(report_id).strip()
report = ReportRepository.find_by_id(report_id)
if not report:
raise GraphQLError("Report with the given ID does not exist.")
ReportRepository.delete_by_id(report_id)
ReportService._remove_from_google_sheet(report_id)
return report

@staticmethod
def _mirror_to_google_sheet(report):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially since it seems that our database is our source of truth — with the Google Sheets just being used as a convenient interface instead — look into making this call a background job/asynchronous.

Currently, whenever the CreateReport mutation is called, we call this method within it, but this could delay our API's response to the client (each create_report call authenticates to Google, opens the sheet and appends a row before responding, which takes additional time).

"""Best-effort sheet mirroring; the database remains the source of truth."""
sheet_id = os.getenv("BUG_REPORT_SHEET_ID") or os.getenv("SHEET_KEY")
credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or os.getenv(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GOOGLE_APPLICATION_CREDENTIALS is the Firebase Admin service-account key. app.py:33 reads the same variable and initialize_firebase() refuses to start without it. Two consequences worth thinking about:

  1. The if not sheet_id or not credentials_path: return guard is just if not sheet_id, since credentials_path is always set.
  2. I think to make it actually work you'd have to share the spreadsheet with the Firebase Admin service account, which widens what that key can reach beyond Firebase.

A dedicated BUG_REPORT_SHEET_CREDENTIALS (or at least a separate service account with only Sheets scope) separates Firebase key permissions from Google Sheet functionalities.

"GOOGLE_SERVICE_ACCOUNT_PATH"
)
if not sheet_id or not credentials_path:
return

try:
import gspread

client = gspread.service_account(filename=credentials_path)
sheet_name = os.getenv("BUG_REPORT_SHEET_NAME", "Bug Reports")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the "Bug Reports" fallback a constant and include it in utils

worksheet = client.open_by_key(sheet_id).worksheet(sheet_name)
worksheet.append_row(
[
report.id,
report.issue,
report.sport or "",
report.description or "",
report.created_at.isoformat(),
]
)
except Exception:
# A reporting submission should not fail because an external mirror is
# unavailable. Log the failure without including the report body.
logging.exception("Could not mirror report %s to Google Sheets", report.id)

@staticmethod
def _remove_from_google_sheet(report_id):
"""Best-effort removal from the optional Google Sheets mirror."""
sheet_id = os.getenv("BUG_REPORT_SHEET_ID") or os.getenv("SHEET_KEY")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not blocking: _mirror_to_google_sheet and _remove_from_google_sheet have the same setup code. Can we pull it into a _get_worksheet() helper and cache the client so we're not logging in to Google on every call?

credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or os.getenv(
"GOOGLE_SERVICE_ACCOUNT_PATH"
)
if not sheet_id or not credentials_path:
return

try:
import gspread

client = gspread.service_account(filename=credentials_path)
sheet_name = os.getenv("BUG_REPORT_SHEET_NAME", "Bug Reports")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here

worksheet = client.open_by_key(sheet_id).worksheet(sheet_name)
try:
cell = worksheet.find(str(report_id), in_column=1)
except gspread.exceptions.CellNotFound:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude checked for gspread.exceptions.CellNotFound, and it doesn't exist in gspread 6.x., so this is dead code. Not sure what testing looked like for this specific exception, but suspect it only “worked” because the outer except Exception catches the resulting AttributeError

return
if cell:
worksheet.delete_rows(cell.row)
except Exception:
logging.exception("Could not remove report %s from Google Sheets", report_id)

16 changes: 15 additions & 1 deletion src/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from graphene import ID, ObjectType, Field, String, List, Int
from graphene import ID, DateTime, ObjectType, Field, String, List, Int
from datetime import datetime


Expand All @@ -22,6 +22,20 @@ class UserType(ObjectType):
def resolve_id(user, info):
return str(user.id)


class ReportType(ObjectType):
"""A user-submitted bug or data-quality report."""

id = String(required=True)
issue = String(required=True)
sport = String()
description = String()
created_at = DateTime(required=True)

def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)

class TeamType(ObjectType):
"""
A GraphQL type representing a team.
Expand Down
Loading