Repository navigation
Add report functionality with creation, deletion, and querying capabilities #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,5 +13,6 @@ pytz | |
| gunicorn | ||
| flask-socketio | ||
| firebase-admin==7.3.0 | ||
| gspread | ||
| pytest | ||
| simple-websocket | ||
| 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"), | ||
| ) |
| 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)) |
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will cause a Also note this import added a second |
||
| from src.services.user_service import UserService | ||
| from src.types import FavoriteTeamType, GameType | ||
| from src.utils.graphql_errors import graphql_jwt_required | ||
|
|
||
| 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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How does |
||
| 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) | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| from bson import ObjectId | ||
| from pymongo import ReturnDocument | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| from src.database import db | ||
| from src.models.user import User | ||
|
|
||
| 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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
| 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| """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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A dedicated |
||
| "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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, not blocking: |
||
| 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude checked for |
||
| return | ||
| if cell: | ||
| worksheet.delete_rows(cell.row) | ||
| except Exception: | ||
| logging.exception("Could not remove report %s from Google Sheets", report_id) | ||
|
|
||
There was a problem hiding this comment.
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