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..48428935 --- /dev/null +++ b/tools/migrations/26-02-16--add_translation_search.sql @@ -0,0 +1,14 @@ +-- Translation search history table +-- 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, + meaning_id INT NOT NULL, + search_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES user(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..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 +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 @@ -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,52 @@ 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 for each translation + 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 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)) +@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/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 = { 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..4ed36de9 --- /dev/null +++ b/zeeguu/core/model/translation_search.py @@ -0,0 +1,69 @@ +from datetime import datetime +from sqlalchemy import desc + +from zeeguu.core.model.db import db +from zeeguu.core.model.meaning import Meaning +from zeeguu.core.model.user import User + + +class TranslationSearch(db.Model): + """ + Tracks successful translation searches made in the Translation Tab. + Only logs searches where a translation was found (meaning exists). + """ + + __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) + + 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, meaning: Meaning): + self.user = user + self.meaning = meaning + self.search_time = datetime.now() + + def __repr__(self): + return f"TranslationSearch({self.meaning.origin.content})" + + @classmethod + def log_search(cls, session, user: User, meaning: Meaning): + """ + Log a translation search to history. + + Note: Does not commit - caller is responsible for committing. + """ + search = cls(user=user, meaning=meaning) + session.add(search) + return search + + @classmethod + def get_history(cls, user: User, limit: int = 50): + """ + Get recent translation searches for a user. + Returns most recent searches first. + """ + 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.""" + return { + "id": self.id, + "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(), + }