From 61ab9d042a2b1815760d73758133e7f2cca51e53 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Tue, 17 Feb 2026 14:59:02 +0100 Subject: [PATCH 1/4] Add translation history tracking for Translation Tab - Add translation_search table to track user searches - Create TranslationSearch model with log_search() and get_history() - Modify /get_multiple_translations to save Meaning records and log searches - Add /translation_history endpoint for retrieving search history Co-Authored-By: Claude Opus 4.5 --- .../26-02-16--add_translation_search.sql | 18 +++ zeeguu/api/endpoints/translation.py | 50 +++++++- zeeguu/core/model/__init__.py | 3 + zeeguu/core/model/translation_search.py | 111 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tools/migrations/26-02-16--add_translation_search.sql create mode 100644 zeeguu/core/model/translation_search.py diff --git a/tools/migrations/26-02-16--add_translation_search.sql b/tools/migrations/26-02-16--add_translation_search.sql new file mode 100644 index 00000000..3d096cbc --- /dev/null +++ b/tools/migrations/26-02-16--add_translation_search.sql @@ -0,0 +1,18 @@ +-- Translation search history table +-- Tracks searches made in the Translation Tab for history view +CREATE TABLE translation_search ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + search_word VARCHAR(255) NOT NULL, + search_word_language_id INT NOT NULL, + target_language_id INT NOT NULL, + meaning_id INT NULL, + search_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (search_word_language_id) REFERENCES language(id), + FOREIGN KEY (target_language_id) REFERENCES language(id), + FOREIGN KEY (meaning_id) REFERENCES meaning(id), + + INDEX idx_user_time (user_id, search_time DESC) +); diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 23b84d1f..341d148a 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -18,7 +18,7 @@ from zeeguu.core.crowd_translations import ( get_own_past_translation, ) -from zeeguu.core.model import Bookmark, User, Meaning, UserWord, UserMweOverride +from zeeguu.core.model import Bookmark, User, Meaning, UserWord, UserMweOverride, TranslationSearch, Language from zeeguu.core.model.article import Article from zeeguu.core.model.bookmark_context import BookmarkContext from zeeguu.core.model.context_identifier import ContextIdentifier @@ -176,6 +176,7 @@ def get_one_translation(from_lang_code, to_lang_code): def get_multiple_translations(from_lang_code, to_lang_code): """ Returns a list of possible translations from multiple services. + Also saves Meaning records and logs to translation history. :return: json array with translations from Azure, Microsoft, and Google """ @@ -187,9 +188,56 @@ def get_multiple_translations(from_lang_code, to_lang_code): translations = get_all_translations(word_str, context, from_lang_code, to_lang_code, is_separated_mwe, full_sentence_context) + # Save meanings and log search to history + user = User.find_by_id(flask.g.user_id) + from_lang = Language.find_or_create(from_lang_code) + to_lang = Language.find_or_create(to_lang_code) + + first_meaning = None + for t in translations: + translation_text = t.get("translation", "") + if translation_text: + meaning = Meaning.find_or_create( + db_session, + word_str, + from_lang_code, + translation_text, + to_lang_code, + ) + t["meaning_id"] = meaning.id + if first_meaning is None: + first_meaning = meaning + + # Log search to history + TranslationSearch.log_search( + db_session, + user=user, + search_word=word_str, + search_word_language=from_lang, + target_language=to_lang, + meaning=first_meaning, + ) + return json_result(dict(translations=translations)) +@api.route("/translation_history", methods=["GET"]) +@cross_domain +@requires_session +def get_translation_history(): + """ + Returns recent translation searches for the current user. + Used by the Translation Tab's history view. + + :return: json array with recent searches + """ + user = User.find_by_id(flask.g.user_id) + limit = request.args.get("limit", 50, type=int) + + searches = TranslationSearch.get_history(user, limit=limit) + return json_result([s.as_dict() for s in searches]) + + @api.route( "/get_translations_stream//", methods=["POST"] ) diff --git a/zeeguu/core/model/__init__.py b/zeeguu/core/model/__init__.py index fddf7f23..dbca4170 100644 --- a/zeeguu/core/model/__init__.py +++ b/zeeguu/core/model/__init__.py @@ -110,3 +110,6 @@ # stats caching from .monthly_active_users_cache import MonthlyActiveUsersCache from .monthly_activity_stats_cache import MonthlyActivityStatsCache + +# translation history +from .translation_search import TranslationSearch diff --git a/zeeguu/core/model/translation_search.py b/zeeguu/core/model/translation_search.py new file mode 100644 index 00000000..2ec23b16 --- /dev/null +++ b/zeeguu/core/model/translation_search.py @@ -0,0 +1,111 @@ +from datetime import datetime +from sqlalchemy import desc + +from zeeguu.core.model.db import db +from zeeguu.core.model.language import Language +from zeeguu.core.model.meaning import Meaning +from zeeguu.core.model.user import User + + +class TranslationSearch(db.Model): + """ + Tracks translation searches made in the Translation Tab. + Used to show search history and understand user lookup patterns. + """ + + __tablename__ = "translation_search" + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + user = db.relationship(User) + + search_word = db.Column(db.String(255), nullable=False) + + search_word_language_id = db.Column( + db.Integer, db.ForeignKey(Language.id), nullable=False + ) + search_word_language = db.relationship( + Language, primaryjoin=search_word_language_id == Language.id + ) + + target_language_id = db.Column( + db.Integer, db.ForeignKey(Language.id), nullable=False + ) + target_language = db.relationship( + Language, primaryjoin=target_language_id == Language.id + ) + + meaning_id = db.Column(db.Integer, db.ForeignKey(Meaning.id), nullable=True) + meaning = db.relationship(Meaning) + + search_time = db.Column(db.DateTime, nullable=False, default=datetime.now) + + def __init__( + self, + user: User, + search_word: str, + search_word_language: Language, + target_language: Language, + meaning: Meaning = None, + ): + self.user = user + self.search_word = search_word + self.search_word_language = search_word_language + self.target_language = target_language + self.meaning = meaning + self.search_time = datetime.now() + + def __repr__(self): + return f"TranslationSearch({self.search_word}, {self.search_word_language.code} -> {self.target_language.code})" + + @classmethod + def log_search( + cls, + session, + user: User, + search_word: str, + search_word_language: Language, + target_language: Language, + meaning: Meaning = None, + ): + """Log a translation search to history.""" + search = cls( + user=user, + search_word=search_word, + search_word_language=search_word_language, + target_language=target_language, + meaning=meaning, + ) + session.add(search) + session.commit() + return search + + @classmethod + def get_history(cls, user: User, limit: int = 50): + """ + Get recent translation searches for a user. + Returns most recent searches first, with meaning details if available. + """ + return ( + cls.query.filter(cls.user_id == user.id) + .order_by(desc(cls.search_time)) + .limit(limit) + .all() + ) + + def as_dict(self): + """Return dictionary representation for API response.""" + result = { + "id": self.id, + "search_word": self.search_word, + "from_language": self.search_word_language.code, + "to_language": self.target_language.code, + "search_time": self.search_time.isoformat(), + } + + if self.meaning: + result["translation"] = self.meaning.translation.content + result["meaning_id"] = self.meaning.id + + return result From 7865804593e95125821f474bdc1bbbff8f28bf0b Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Tue, 17 Feb 2026 19:24:31 +0100 Subject: [PATCH 2/4] Fix translation history: remove internal commit, add error handling - Remove db_session.commit() from log_search method (follows codebase pattern) - Add try/except around history logging to prevent request failures - Document the commit responsibility in docstring Co-Authored-By: Claude Opus 4.5 --- zeeguu/api/endpoints/translation.py | 23 ++++++++++++++--------- zeeguu/core/model/translation_search.py | 8 ++++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 341d148a..c321df55 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -208,15 +208,20 @@ def get_multiple_translations(from_lang_code, to_lang_code): if first_meaning is None: first_meaning = meaning - # Log search to history - TranslationSearch.log_search( - db_session, - user=user, - search_word=word_str, - search_word_language=from_lang, - target_language=to_lang, - meaning=first_meaning, - ) + # Log search to history (non-critical - don't fail request if logging fails) + try: + TranslationSearch.log_search( + db_session, + user=user, + search_word=word_str, + search_word_language=from_lang, + target_language=to_lang, + meaning=first_meaning, + ) + db_session.commit() + except Exception as e: + db_session.rollback() + zeeguu_log(f"[TRANSLATION] Failed to log search history: {e}") return json_result(dict(translations=translations)) diff --git a/zeeguu/core/model/translation_search.py b/zeeguu/core/model/translation_search.py index 2ec23b16..da715d9e 100644 --- a/zeeguu/core/model/translation_search.py +++ b/zeeguu/core/model/translation_search.py @@ -69,7 +69,12 @@ def log_search( target_language: Language, meaning: Meaning = None, ): - """Log a translation search to history.""" + """ + Log a translation search to history. + + Note: Does not commit - caller is responsible for committing. + This follows the pattern of other log_* methods (ValidationLog, GrammarCorrectionLog). + """ search = cls( user=user, search_word=search_word, @@ -78,7 +83,6 @@ def log_search( meaning=meaning, ) session.add(search) - session.commit() return search @classmethod From e34eda3457dfa990dd9ea9a9961f8f836bbd704e Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Tue, 17 Feb 2026 19:44:53 +0100 Subject: [PATCH 3/4] Fix test fixtures: verify email and correct status code - Mark test user email as verified in LoggedInClient fixture - Fix test_student_does_not_have_access_to_cohort: expect 403 (Forbidden) not 401 (Unauthorized) - student is authenticated but not authorized Co-Authored-By: Claude Opus 4.5 --- zeeguu/api/test/fixtures.py | 6 ++++++ zeeguu/api/test/test_teacher_dashboard.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/test/fixtures.py b/zeeguu/api/test/fixtures.py index be033eb5..e7999fe7 100644 --- a/zeeguu/api/test/fixtures.py +++ b/zeeguu/api/test/fixtures.py @@ -55,6 +55,12 @@ def __init__(self, client): print(response.data) print(self.session) + # Mark email as verified for tests + from zeeguu.core.model import User + user = User.find(self.email) + user.email_verified = True + db_session.commit() + def append_session(self, url): if "?" in url: return url + "&session=" + self.session diff --git a/zeeguu/api/test/test_teacher_dashboard.py b/zeeguu/api/test/test_teacher_dashboard.py index 603a03a3..054fccc3 100644 --- a/zeeguu/api/test/test_teacher_dashboard.py +++ b/zeeguu/api/test/test_teacher_dashboard.py @@ -73,8 +73,9 @@ def test_student_does_not_have_access_to_cohort(client): student_session = response.data.decode("utf-8") # Ensure student user can't access /cohorts_info + # 403 Forbidden: authenticated but not authorized (not a teacher) response = client.client.get(f"/cohorts_info?session={student_session}") - assert response.status_code == 401 + assert response.status_code == 403 FRENCH_B1_COHORT = { From 62291821c2b7dbac6bd176a19f116e36caa07855 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Tue, 17 Feb 2026 19:50:09 +0100 Subject: [PATCH 4/4] Simplify translation_search: only log when meaning exists - Remove redundant columns (search_word, languages) - derive from meaning - Only log searches that found translations (meaning_id NOT NULL) - Cleaner schema: just user_id, meaning_id, search_time Co-Authored-By: Claude Opus 4.5 --- .../26-02-16--add_translation_search.sql | 10 +-- zeeguu/api/endpoints/translation.py | 31 +++----- zeeguu/core/model/translation_search.py | 74 ++++--------------- 3 files changed, 28 insertions(+), 87 deletions(-) diff --git a/tools/migrations/26-02-16--add_translation_search.sql b/tools/migrations/26-02-16--add_translation_search.sql index 3d096cbc..48428935 100644 --- a/tools/migrations/26-02-16--add_translation_search.sql +++ b/tools/migrations/26-02-16--add_translation_search.sql @@ -1,17 +1,13 @@ -- Translation search history table --- Tracks searches made in the Translation Tab for history view +-- Tracks successful searches made in the Translation Tab for history view +-- Only logs when a translation was found (meaning exists) CREATE TABLE translation_search ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, - search_word VARCHAR(255) NOT NULL, - search_word_language_id INT NOT NULL, - target_language_id INT NOT NULL, - meaning_id INT NULL, + meaning_id INT NOT NULL, search_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id), - FOREIGN KEY (search_word_language_id) REFERENCES language(id), - FOREIGN KEY (target_language_id) REFERENCES language(id), FOREIGN KEY (meaning_id) REFERENCES meaning(id), INDEX idx_user_time (user_id, search_time DESC) diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index c321df55..fb3c453e 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -18,7 +18,7 @@ from zeeguu.core.crowd_translations import ( get_own_past_translation, ) -from zeeguu.core.model import Bookmark, User, Meaning, UserWord, UserMweOverride, TranslationSearch, Language +from zeeguu.core.model import Bookmark, User, Meaning, UserWord, UserMweOverride, TranslationSearch from zeeguu.core.model.article import Article from zeeguu.core.model.bookmark_context import BookmarkContext from zeeguu.core.model.context_identifier import ContextIdentifier @@ -188,11 +188,7 @@ def get_multiple_translations(from_lang_code, to_lang_code): translations = get_all_translations(word_str, context, from_lang_code, to_lang_code, is_separated_mwe, full_sentence_context) - # Save meanings and log search to history - user = User.find_by_id(flask.g.user_id) - from_lang = Language.find_or_create(from_lang_code) - to_lang = Language.find_or_create(to_lang_code) - + # Save meanings for each translation first_meaning = None for t in translations: translation_text = t.get("translation", "") @@ -208,20 +204,15 @@ def get_multiple_translations(from_lang_code, to_lang_code): if first_meaning is None: first_meaning = meaning - # Log search to history (non-critical - don't fail request if logging fails) - try: - TranslationSearch.log_search( - db_session, - user=user, - search_word=word_str, - search_word_language=from_lang, - target_language=to_lang, - meaning=first_meaning, - ) - db_session.commit() - except Exception as e: - db_session.rollback() - zeeguu_log(f"[TRANSLATION] Failed to log search history: {e}") + # Log search to history only if we found a translation + if first_meaning: + try: + user = User.find_by_id(flask.g.user_id) + TranslationSearch.log_search(db_session, user, first_meaning) + db_session.commit() + except Exception as e: + db_session.rollback() + zeeguu_log(f"[TRANSLATION] Failed to log search history: {e}") return json_result(dict(translations=translations)) diff --git a/zeeguu/core/model/translation_search.py b/zeeguu/core/model/translation_search.py index da715d9e..4ed36de9 100644 --- a/zeeguu/core/model/translation_search.py +++ b/zeeguu/core/model/translation_search.py @@ -2,15 +2,14 @@ from sqlalchemy import desc from zeeguu.core.model.db import db -from zeeguu.core.model.language import Language from zeeguu.core.model.meaning import Meaning from zeeguu.core.model.user import User class TranslationSearch(db.Model): """ - Tracks translation searches made in the Translation Tab. - Used to show search history and understand user lookup patterns. + Tracks successful translation searches made in the Translation Tab. + Only logs searches where a translation was found (meaning exists). """ __tablename__ = "translation_search" @@ -20,68 +19,27 @@ class TranslationSearch(db.Model): user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) user = db.relationship(User) - search_word = db.Column(db.String(255), nullable=False) - - search_word_language_id = db.Column( - db.Integer, db.ForeignKey(Language.id), nullable=False - ) - search_word_language = db.relationship( - Language, primaryjoin=search_word_language_id == Language.id - ) - - target_language_id = db.Column( - db.Integer, db.ForeignKey(Language.id), nullable=False - ) - target_language = db.relationship( - Language, primaryjoin=target_language_id == Language.id - ) - - meaning_id = db.Column(db.Integer, db.ForeignKey(Meaning.id), nullable=True) + meaning_id = db.Column(db.Integer, db.ForeignKey(Meaning.id), nullable=False) meaning = db.relationship(Meaning) search_time = db.Column(db.DateTime, nullable=False, default=datetime.now) - def __init__( - self, - user: User, - search_word: str, - search_word_language: Language, - target_language: Language, - meaning: Meaning = None, - ): + def __init__(self, user: User, meaning: Meaning): self.user = user - self.search_word = search_word - self.search_word_language = search_word_language - self.target_language = target_language self.meaning = meaning self.search_time = datetime.now() def __repr__(self): - return f"TranslationSearch({self.search_word}, {self.search_word_language.code} -> {self.target_language.code})" + return f"TranslationSearch({self.meaning.origin.content})" @classmethod - def log_search( - cls, - session, - user: User, - search_word: str, - search_word_language: Language, - target_language: Language, - meaning: Meaning = None, - ): + def log_search(cls, session, user: User, meaning: Meaning): """ Log a translation search to history. Note: Does not commit - caller is responsible for committing. - This follows the pattern of other log_* methods (ValidationLog, GrammarCorrectionLog). """ - search = cls( - user=user, - search_word=search_word, - search_word_language=search_word_language, - target_language=target_language, - meaning=meaning, - ) + search = cls(user=user, meaning=meaning) session.add(search) return search @@ -89,7 +47,7 @@ def log_search( def get_history(cls, user: User, limit: int = 50): """ Get recent translation searches for a user. - Returns most recent searches first, with meaning details if available. + Returns most recent searches first. """ return ( cls.query.filter(cls.user_id == user.id) @@ -100,16 +58,12 @@ def get_history(cls, user: User, limit: int = 50): def as_dict(self): """Return dictionary representation for API response.""" - result = { + return { "id": self.id, - "search_word": self.search_word, - "from_language": self.search_word_language.code, - "to_language": self.target_language.code, + "search_word": self.meaning.origin.content, + "translation": self.meaning.translation.content, + "from_language": self.meaning.origin.language.code, + "to_language": self.meaning.translation.language.code, + "meaning_id": self.meaning.id, "search_time": self.search_time.isoformat(), } - - if self.meaning: - result["translation"] = self.meaning.translation.content - result["meaning_id"] = self.meaning.id - - return result