From e7a4aeb50410e819fa1d90cfe3cbd9b191aa803d Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Sat, 14 Feb 2026 14:42:48 +0100 Subject: [PATCH] Add report exercise issue feature (backend) - Add exercise_report table migration with FK to exercise_source - Create ExerciseReport model with reason enum (word_not_shown, context_confusing, wrong_translation, context_wrong, other) - Add POST /report_exercise_issue API endpoint - Unique constraint prevents duplicate reports per user/bookmark/source Co-Authored-By: Claude Opus 4.5 --- .../26-02-13--add_exercise_report.sql | 15 ++++ zeeguu/api/endpoints/exercises.py | 87 +++++++++++++++++++ zeeguu/core/model/__init__.py | 1 + zeeguu/core/model/exercise_report.py | 83 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 tools/migrations/26-02-13--add_exercise_report.sql create mode 100644 zeeguu/core/model/exercise_report.py diff --git a/tools/migrations/26-02-13--add_exercise_report.sql b/tools/migrations/26-02-13--add_exercise_report.sql new file mode 100644 index 00000000..eab2ad1a --- /dev/null +++ b/tools/migrations/26-02-13--add_exercise_report.sql @@ -0,0 +1,15 @@ +CREATE TABLE exercise_report ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + bookmark_id INT NOT NULL, + exercise_source_id INT NOT NULL, + reason ENUM('word_not_shown', 'context_confusing', 'wrong_translation', 'context_wrong', 'other') NOT NULL, + comment TEXT DEFAULT NULL, + context_used TEXT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + resolved BOOLEAN DEFAULT FALSE, + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (bookmark_id) REFERENCES bookmark(id), + FOREIGN KEY (exercise_source_id) REFERENCES exercise_source(id), + UNIQUE KEY unique_user_bookmark_source (user_id, bookmark_id, exercise_source_id) +); diff --git a/zeeguu/api/endpoints/exercises.py b/zeeguu/api/endpoints/exercises.py index 383da166..e0e9b673 100644 --- a/zeeguu/api/endpoints/exercises.py +++ b/zeeguu/api/endpoints/exercises.py @@ -345,3 +345,90 @@ def _user_words_as_json_result(user_words): log("Failed to commit UserWord deletions") return json_result(dicts) + + +# ==================================== +# Report Exercise Issue +# ==================================== + + +@api.route("/report_exercise_issue", methods=["POST"]) +@cross_domain +@requires_session +def report_exercise_issue(): + """ + Report a problem with an exercise. + + Request body (JSON): + { + "bookmark_id": 123, + "exercise_source": "FindWordInContextCloze", + "reason": "word_not_shown", // word_not_shown, context_confusing, wrong_translation, context_wrong, other + "comment": "Optional details", // optional + "context_used": "The context sentence..." // optional + } + + Response (200): + { + "success": true, + "message": "Report submitted", + "report_id": 123 + } + """ + from zeeguu.logging import log + from zeeguu.core.model import ExerciseReport, ExerciseSource + + user = User.find_by_id(flask.g.user_id) + data = request.get_json() + + if not data: + return json_result({"error": "JSON body required"}), 400 + + bookmark_id = data.get("bookmark_id") + exercise_source_name = data.get("exercise_source", "").strip() + reason = data.get("reason", "").strip() + comment = (data.get("comment") or "").strip() or None + context_used = (data.get("context_used") or "").strip() or None + + if not bookmark_id or not exercise_source_name or not reason: + return json_result({"error": "bookmark_id, exercise_source, and reason are required"}), 400 + + valid_reasons = ["word_not_shown", "context_confusing", "wrong_translation", "context_wrong", "other"] + if reason not in valid_reasons: + return json_result({"error": f"reason must be one of: {valid_reasons}"}), 400 + + # Find the bookmark + bookmark = Bookmark.find(bookmark_id) + if not bookmark: + return json_result({"error": "Bookmark not found"}), 404 + + # Find or create the exercise source + exercise_source = ExerciseSource.find_or_create(db_session, exercise_source_name) + + # Check if already reported + existing = ExerciseReport.find_by_user_bookmark_source( + user.id, bookmark.id, exercise_source.id + ) + if existing: + return json_result({ + "success": True, + "message": "You have already reported this exercise", + "report_id": existing.id, + "already_reported": True + }) + + try: + report = ExerciseReport.create( + db_session, user, bookmark, exercise_source, reason, comment, context_used + ) + log(f"User {user.id} reported exercise issue for bookmark {bookmark.id}: {reason}") + + return json_result({ + "success": True, + "message": "Thanks for the feedback!", + "report_id": report.id + }) + except Exception as e: + log(f"Error creating exercise report: {e}") + traceback.print_exc() + return json_result({"error": "Failed to submit report"}), 500 diff --git a/zeeguu/core/model/__init__.py b/zeeguu/core/model/__init__.py index 99a12118..fddf7f23 100644 --- a/zeeguu/core/model/__init__.py +++ b/zeeguu/core/model/__init__.py @@ -51,6 +51,7 @@ from .exercise import Exercise from .exercise_outcome import ExerciseOutcome from .exercise_source import ExerciseSource +from .exercise_report import ExerciseReport # user logging from .user_activitiy_data import UserActivityData diff --git a/zeeguu/core/model/exercise_report.py b/zeeguu/core/model/exercise_report.py new file mode 100644 index 00000000..ac348b5d --- /dev/null +++ b/zeeguu/core/model/exercise_report.py @@ -0,0 +1,83 @@ +from datetime import datetime + +from zeeguu.core.model.db import db + + +class ExerciseReport(db.Model): + """ + User reports for broken or problematic exercises. + + Used to flag issues like missing word in cloze, bad translations, + confusing context, etc. + """ + + __tablename__ = "exercise_report" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + bookmark_id = db.Column(db.Integer, db.ForeignKey("bookmark.id"), nullable=False) + exercise_source_id = db.Column( + db.Integer, db.ForeignKey("exercise_source.id"), nullable=False + ) + reason = db.Column( + db.Enum( + "word_not_shown", + "context_confusing", + "wrong_translation", + "context_wrong", + "other", + ), + nullable=False, + ) + comment = db.Column(db.Text, nullable=True) + context_used = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.now) + resolved = db.Column(db.Boolean, default=False) + + user = db.relationship("User") + bookmark = db.relationship("Bookmark", backref="exercise_reports") + exercise_source = db.relationship("ExerciseSource") + + def __init__( + self, user, bookmark, exercise_source, reason, comment=None, context_used=None + ): + self.user_id = user.id + self.bookmark_id = bookmark.id + self.exercise_source_id = exercise_source.id + self.reason = reason + self.comment = comment + self.context_used = context_used + + @classmethod + def create( + cls, session, user, bookmark, exercise_source, reason, comment=None, context_used=None + ): + report = cls(user, bookmark, exercise_source, reason, comment, context_used) + session.add(report) + session.commit() + return report + + @classmethod + def find_by_user_bookmark_source(cls, user_id, bookmark_id, exercise_source_id): + return cls.query.filter_by( + user_id=user_id, + bookmark_id=bookmark_id, + exercise_source_id=exercise_source_id, + ).first() + + @classmethod + def count_for_bookmark(cls, bookmark_id): + return cls.query.filter_by(bookmark_id=bookmark_id, resolved=False).count() + + def as_dictionary(self): + return { + "id": self.id, + "user_id": self.user_id, + "bookmark_id": self.bookmark_id, + "exercise_source_id": self.exercise_source_id, + "reason": self.reason, + "comment": self.comment, + "context_used": self.context_used, + "created_at": self.created_at.isoformat() if self.created_at else None, + "resolved": self.resolved, + }