diff --git a/tools/migrations/26-05-03--add_onboarding_message_table.sql b/tools/migrations/26-05-03--add_onboarding_message_table.sql new file mode 100644 index 00000000..11acdd4b --- /dev/null +++ b/tools/migrations/26-05-03--add_onboarding_message_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE `zeeguu_test`.`onboarding_message` ( + `id` INT NOT NULL AUTO_INCREMENT, + `type` VARCHAR(45) NULL, + PRIMARY KEY (`id`) +); + +INSERT INTO onboarding_message (id, type) +VALUES + (1, 'TRANSLATE_MSG'), + (2, 'UNSELECT_MSG'), + (3, 'REVIEW_WORDS_MSG'), + (4, 'PRACTICE_MSG'), + (5, 'DAILY_EXERCISES_MSG'), + (6, 'WORD_LEVELS_MSG'), + (7, 'LISTENING_MSG'); diff --git a/tools/migrations/26-05-03--add_user_onboarding_message_table.sql b/tools/migrations/26-05-03--add_user_onboarding_message_table.sql new file mode 100644 index 00000000..92f544f1 --- /dev/null +++ b/tools/migrations/26-05-03--add_user_onboarding_message_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `zeeguu_test`.`user_onboarding_message` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `onboarding_message_id` INT NOT NULL, + `message_shown_time` DATETIME NULL, + `message_click_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `ux_user_onboarding_message_user_message` (`user_id`, `onboarding_message_id`), + INDEX `user_onboarding_message_ibfk_1_idx` (`user_id` ASC), + INDEX `user_onboarding_message_ibfk_2_idx` (`onboarding_message_id` ASC), + CONSTRAINT `user_onboarding_message_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `zeeguu_test`.`user` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `user_onboarding_message_ibfk_2` FOREIGN KEY (`onboarding_message_id`) REFERENCES `zeeguu_test`.`onboarding_message` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION +); diff --git a/zeeguu/api/endpoints/__init__.py b/zeeguu/api/endpoints/__init__.py index 3842d788..220ecccf 100644 --- a/zeeguu/api/endpoints/__init__.py +++ b/zeeguu/api/endpoints/__init__.py @@ -33,6 +33,7 @@ from . import speech from . import own_texts from . import user_notifications +from . import user_onboarding_message from .student import * from .nlp import * from .reading_sessions import * diff --git a/zeeguu/api/endpoints/user_onboarding_message.py b/zeeguu/api/endpoints/user_onboarding_message.py new file mode 100644 index 00000000..fbc6d719 --- /dev/null +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -0,0 +1,88 @@ +import flask + +from zeeguu.core.model.user_onboarding_message import UserOnboardingMessage +from zeeguu.core.model.user import User +from zeeguu.api.utils.json_result import json_result +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from . import api, db_session + +@api.route("/get_onboarding_message_status", methods=["GET"]) +@cross_domain +@requires_session +def get_onboarding_message_status(): + """ + Checks whether the onboarding message was already shown to the user. + This endpoint is read-only and returns a boolean only. + """ + onboarding_message_id = flask.request.args.get("onboarding_message_id", None) + + if not onboarding_message_id: + return json_result({"error": "onboarding_message_id required"}, status=400) + + try: + mid = int(onboarding_message_id) + except ValueError: + return json_result({"error": "onboarding_message_id must be an integer"}, status=400) + + return json_result({"shown": UserOnboardingMessage.has_message_shown_time(flask.g.user_id, mid)}) + + +@api.route("/mark_onboarding_message_shown", methods=["POST"]) # canonical name +@cross_domain +@requires_session +def mark_onboarding_message_shown(): + """ + Records that an onboarding message was shown to the user. + Frontend calls this when the message appears on screen. + Uses lazy creation: if the user/message row does not exist yet, + it is created now and marked with a shown timestamp. + """ + data = flask.request.form + onboarding_message_id = data.get("onboarding_message_id", None) + + if not onboarding_message_id: + return json_result({"error": "onboarding_message_id required"}, status=400) + + try: + mid = int(onboarding_message_id) + except ValueError: + return json_result({"error": "onboarding_message_id must be an integer"}, status=400) + + user_onboarding_message = UserOnboardingMessage.find_or_create_for_user_and_message( + db_session, flask.g.user_id, mid + ) + UserOnboardingMessage.set_message_shown_time(user_onboarding_message.id, db_session) + db_session.commit() + + onboarding_data = { + "user_onboarding_message_id": user_onboarding_message.id, + "onboarding_message_id": int(onboarding_message_id), + } + + return json_result(onboarding_data) + + +@api.route("/set_onboarding_message_click_time", methods=["POST"]) +@cross_domain +@requires_session +def set_onboarding_message_click_time(): + data = flask.request.form + onboarding_message_id = data.get("onboarding_message_id", None) + + if not onboarding_message_id: + return json_result({"error": "onboarding_message_id required"}, status=400) + + try: + mid = int(onboarding_message_id) + except ValueError: + return json_result({"error": "onboarding_message_id must be an integer"}, status=400) + + user_onboarding_message = UserOnboardingMessage.find_by_user_and_message(flask.g.user_id, mid) + + if not user_onboarding_message: + return json_result({"error": "not found"}, status=404) + + UserOnboardingMessage.update_user_onboarding_message_time(user_onboarding_message.id, db_session) + db_session.commit() + + return "OK" \ No newline at end of file diff --git a/zeeguu/api/test/test_onboarding_message.py b/zeeguu/api/test/test_onboarding_message.py new file mode 100644 index 00000000..f69f2816 --- /dev/null +++ b/zeeguu/api/test/test_onboarding_message.py @@ -0,0 +1,94 @@ +""" +Tests for onboarding message endpoints and model. +""" +import json +import pytest +from fixtures import logged_in_client as client +from zeeguu.core.model import UserOnboardingMessage, OnboardingMessage +from zeeguu.core.model.db import db + + +def test_get_onboarding_message_status_returns_false_when_not_shown(client): + """Verify status endpoint returns false for messages that haven't been shown yet.""" + response = client.get("/get_onboarding_message_status?onboarding_message_id=1") + assert response["shown"] is False + + +def test_get_onboarding_message_status_returns_true_after_marked_shown(client): + """Verify status endpoint returns true after message is marked shown.""" + # Mark message as shown + client.post("/mark_onboarding_message_shown", data={"onboarding_message_id": 1}) + + # Check status + response = client.get("/get_onboarding_message_status?onboarding_message_id=1") + assert response["shown"] is True + + +def test_click_endpoint_refuses_cross_user_update(app, client): + """Verify a user cannot click another user's message.""" + from fixtures import LoggedInClient + + # Create second user + with app.test_client() as other_client: + other_logged_in = LoggedInClient( + other_client, + email="other@mir.lu", + username="other_user" + ) + + # User 1 marks message 2 as shown + client.post("/mark_onboarding_message_shown", data={"onboarding_message_id": 2}) + + # Verify user 1 has the message + response = client.get("/get_onboarding_message_status?onboarding_message_id=2") + assert response["shown"] is True + + # User 2 tries to click it via a direct DB manipulation attack + response = other_logged_in.response_from_post( + "/set_onboarding_message_click_time", + data={"onboarding_message_id": 2} + ) + assert response.status_code == 404 # User 2 doesn't have this message + + # Verify user 1's message is unchanged (no click time) + from zeeguu.core.model import User + user1 = User.find(client.email) + user_msg = UserOnboardingMessage.find_by_user_and_message(user1.id, 2) + assert user_msg.message_click_time is None + + +def test_find_or_create_idempotency(app): + """Verify find_or_create returns the same row on repeated calls.""" + from zeeguu.core.model import User + + with app.app_context(): + # Create a test user + user = User("test@idempotent.test", "Test User", "password", "test_user") + db.session.add(user) + db.session.commit() + + msg_id = 3 + + # First call: creates the row + record1 = UserOnboardingMessage.find_or_create_for_user_and_message( + db.session, user.id, msg_id + ) + db.session.commit() + row_id_1 = record1.id + + # Second call: should find the existing row + record2 = UserOnboardingMessage.find_or_create_for_user_and_message( + db.session, user.id, msg_id + ) + db.session.commit() + row_id_2 = record2.id + + # Both should be the same + assert row_id_1 == row_id_2 + + # Verify there's exactly one row + all_records = UserOnboardingMessage.query.filter_by( + user_id=user.id, + onboarding_message_id=msg_id + ).all() + assert len(all_records) == 1 \ No newline at end of file diff --git a/zeeguu/core/model/__init__.py b/zeeguu/core/model/__init__.py index c71912f4..7aa03962 100644 --- a/zeeguu/core/model/__init__.py +++ b/zeeguu/core/model/__init__.py @@ -21,6 +21,8 @@ from .user_preference import UserPreference from .session import Session from .unique_code import UniqueCode +from .onboarding_message import OnboardingMessage +from .user_onboarding_message import UserOnboardingMessage from .article_broken_code_map import ArticleBrokenMap, LowQualityTypes diff --git a/zeeguu/core/model/onboarding_message.py b/zeeguu/core/model/onboarding_message.py new file mode 100644 index 00000000..470de59a --- /dev/null +++ b/zeeguu/core/model/onboarding_message.py @@ -0,0 +1,45 @@ +import sqlalchemy + +from zeeguu.core.model.db import db + +class OnboardingMessage(db.Model): + """ + OnboardingMessage reflects the different types of onboarding messages + that the system is able to output. + + """ + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(45)) + + TRANSLATE_MSG = 1 + UNSELECT_MSG = 2 + REVIEW_WORDS_MSG = 3 + PRACTICE_MSG = 4 + DAILY_EXERCISES_MSG = 5 + WORD_LEVELS_MSG = 6 + LISTENING_MSG = 7 + + def __repr__(self): + return f"" + + @classmethod + def find(cls, type): + try: + onboardingMessage = cls.query.filter(cls.type == type).one() + return onboardingMessage + except sqlalchemy.orm.exc.NoResultFound: + return None + + @classmethod + def find_by_id(cls, i): + try: + return cls.query.filter(cls.id == i).one() + except sqlalchemy.orm.exc.NoResultFound: + return None + except Exception as e: + from sentry_sdk import capture_exception + + capture_exception(e) + raise diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py new file mode 100644 index 00000000..0f56d952 --- /dev/null +++ b/zeeguu/core/model/user_onboarding_message.py @@ -0,0 +1,114 @@ +import sqlalchemy + +from zeeguu.core.model.db import db +from zeeguu.core.model.onboarding_message import OnboardingMessage +from zeeguu.core.model.user import User +from zeeguu.logging import log +from datetime import datetime + +class UserOnboardingMessage(db.Model): + """ + An onboarding message that was sent to the user. + If the user clicks it, the message_click_time will have the datetime + when that click was performed. IF not, this field will be null + + """ + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + onboarding_message_id = db.Column(db.Integer, db.ForeignKey(OnboardingMessage.id)) + message_shown_time = db.Column(db.DateTime) + message_click_time = db.Column(db.DateTime, nullable=True) + + def __init__(self, user_id, onboarding_message_id): + self.user_id = user_id + self.onboarding_message_id = onboarding_message_id + + def __repr__(self): + return f"" + + @classmethod + def find_by_id(cls, i): + try: + return cls.query.filter(cls.id == i).one() + except sqlalchemy.orm.exc.NoResultFound: + return None + except Exception as e: + from sentry_sdk import capture_exception + + capture_exception(e) + raise + + @classmethod + def get_all_onboarding_messages_for_user(cls, user_id): + return cls.query.filter(cls.user_id == user_id).all() + + @classmethod + def create_user_onboarding_message(cls, user_id, onboarding_message_id, db_session): + user_onboarding_message = UserOnboardingMessage(user_id, onboarding_message_id) + db_session.add(user_onboarding_message) + return user_onboarding_message + + @classmethod + def set_message_shown_time(cls, user_onboarding_message_id, db_session): + """Set the time when the message was shown to the user.""" + user_onboarding_message = cls.find_by_id(user_onboarding_message_id) + if user_onboarding_message and user_onboarding_message.message_shown_time is None: + user_onboarding_message.message_shown_time = datetime.now() + db_session.add(user_onboarding_message) + return user_onboarding_message + + @classmethod + def find_by_user_and_message(cls, user_id, onboarding_message_id): + return cls.query.filter_by( + user_id=user_id, + onboarding_message_id=onboarding_message_id, + ).first() + + @classmethod + def has_message_shown_time(cls, user_id, onboarding_message_id): + """Return True if a shown timestamp exists for the given user/message.""" + user_onboarding_message = cls.find_by_user_and_message( + user_id, onboarding_message_id + ) + return bool( + user_onboarding_message + and user_onboarding_message.message_shown_time is not None + ) + + @classmethod + def update_user_onboarding_message_time(cls, user_onboarding_message_id, db_session): + """Set the time when the user clicked/dismissed the message.""" + user_onboarding_message = cls.find_by_id(user_onboarding_message_id) + if not user_onboarding_message: + return None + + user_onboarding_message.message_click_time = datetime.now() + db_session.add(user_onboarding_message) + return user_onboarding_message + + @classmethod + def find_or_create_for_user_and_message(cls, session, user_id, onboarding_message_id): + """Find or create a record for a user-message pair.""" + existing = cls.query.filter_by( + user_id=user_id, + onboarding_message_id=onboarding_message_id + ).first() + + if existing: + return existing + + new_record = cls(user_id, onboarding_message_id) + session.add(new_record) + try: + session.flush() + log("Created new user onboarding message record (pending commit)") + return new_record + except sqlalchemy.exc.IntegrityError: + session.rollback() + return cls.query.filter_by( + user_id=user_id, + onboarding_message_id=onboarding_message_id, + ).first()