From 989e9a576e6e7777f1d276e4ef7f943d5ae15206 Mon Sep 17 00:00:00 2001 From: claiireyu Date: Wed, 4 Nov 2026 15:19:06 -0500 Subject: [PATCH] Add report functionality with creation, deletion, and querying capabilities --- requirements.txt | 1 + src/database.py | 5 ++ src/models/__init__.py | 1 + src/models/report.py | 44 ++++++++++ src/mutations/__init__.py | 1 + src/mutations/report.py | 31 +++++++ src/queries/__init__.py | 1 + src/queries/report_query.py | 27 ++++++ src/repositories/__init__.py | 1 + src/repositories/report_repository.py | 32 ++++++++ src/schema.py | 27 +++++- src/services/__init__.py | 1 + src/services/report_service.py | 113 ++++++++++++++++++++++++++ src/types.py | 16 +++- 14 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/models/report.py create mode 100644 src/mutations/report.py create mode 100644 src/queries/report_query.py create mode 100644 src/repositories/report_repository.py create mode 100644 src/services/report_service.py diff --git a/requirements.txt b/requirements.txt index 0edda0c..e8e4902 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ python-dotenv pytz gunicorn firebase-admin==7.3.0 +gspread diff --git a/src/database.py b/src/database.py index 2876748..faf0561 100644 --- a/src/database.py +++ b/src/database.py @@ -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( diff --git a/src/models/__init__.py b/src/models/__init__.py index ef0cdf1..b8116ee 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -3,3 +3,4 @@ from .youtube_video import YoutubeVideo from .article import Article from .user import User +from .report import Report diff --git a/src/models/report.py b/src/models/report.py new file mode 100644 index 0000000..3086102 --- /dev/null +++ b/src/models/report.py @@ -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"), + ) diff --git a/src/mutations/__init__.py b/src/mutations/__init__.py index ed2029d..435ee00 100644 --- a/src/mutations/__init__.py +++ b/src/mutations/__init__.py @@ -7,3 +7,4 @@ from .refresh_access_token import RefreshAccessToken from .logout_user import LogoutUser from .delete_user import DeleteUser +from .report import CreateReport, DeleteReport diff --git a/src/mutations/report.py b/src/mutations/report.py new file mode 100644 index 0000000..551df5d --- /dev/null +++ b/src/mutations/report.py @@ -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)) diff --git a/src/queries/__init__.py b/src/queries/__init__.py index f4052d9..1a59a22 100644 --- a/src/queries/__init__.py +++ b/src/queries/__init__.py @@ -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 diff --git a/src/queries/report_query.py b/src/queries/report_query.py new file mode 100644 index 0000000..b7ecdb3 --- /dev/null +++ b/src/queries/report_query.py @@ -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): + """Read operations for anonymous Score reports.""" + + reports = List( + 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) diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index cc516da..f4206cb 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -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 diff --git a/src/repositories/report_repository.py b/src/repositories/report_repository.py new file mode 100644 index 0000000..7f4200e --- /dev/null +++ b/src/repositories/report_repository.py @@ -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 diff --git a/src/schema.py b/src/schema.py index a0e0b5c..758e53c 100644 --- a/src/schema.py +++ b/src/schema.py @@ -9,11 +9,28 @@ RefreshAccessToken, LogoutUser, DeleteUser, + CreateReport, + DeleteReport, +) +from src.queries import ( + ArticleQuery, + GameQuery, + ReportQuery, + TeamQuery, + UserQuery, + YoutubeVideoQuery, ) -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 @@ -37,6 +54,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.", + ) diff --git a/src/services/__init__.py b/src/services/__init__.py index 111e296..a2746b5 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -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 diff --git a/src/services/report_service.py b/src/services/report_service.py new file mode 100644 index 0000000..1db30e9 --- /dev/null +++ b/src/services/report_service.py @@ -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): + 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: + 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): + """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( + "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") + 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") + 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") + worksheet = client.open_by_key(sheet_id).worksheet(sheet_name) + try: + cell = worksheet.find(str(report_id), in_column=1) + except gspread.exceptions.CellNotFound: + return + if cell: + worksheet.delete_rows(cell.row) + except Exception: + logging.exception("Could not remove report %s from Google Sheets", report_id) + diff --git a/src/types.py b/src/types.py index 64ab2cb..caaa196 100644 --- a/src/types.py +++ b/src/types.py @@ -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 @@ -13,6 +13,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.