From 67e81e871d105800d8dc394c791512706f9354bc Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Mon, 4 May 2026 12:05:02 +0200 Subject: [PATCH 01/10] added db tables and migration for onboarding messages --- ...r_onboarding_message_unique_constraint.sql | 3 + .../26-05-03_add_onboarding_message_table.sql | 15 +++ ...5-03_add_user_onboarding_message_table.sql | 12 +++ zeeguu/api/endpoints/__init__.py | 1 + .../api/endpoints/user_onboarding_message.py | 73 ++++++++++++++ .../user_account_creation.py | 11 +++ zeeguu/core/model/__init__.py | 2 + zeeguu/core/model/onboarding_message.py | 45 +++++++++ zeeguu/core/model/user_onboarding_message.py | 97 +++++++++++++++++++ 9 files changed, 259 insertions(+) create mode 100644 tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql create mode 100644 tools/migrations/26-05-03_add_onboarding_message_table.sql create mode 100644 tools/migrations/26-05-03_add_user_onboarding_message_table.sql create mode 100644 zeeguu/api/endpoints/user_onboarding_message.py create mode 100644 zeeguu/core/model/onboarding_message.py create mode 100644 zeeguu/core/model/user_onboarding_message.py diff --git a/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql b/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql new file mode 100644 index 000000000..e5d55095e --- /dev/null +++ b/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql @@ -0,0 +1,3 @@ +-- Add unique constraint to ensure one record per user/message +ALTER TABLE `zeeguu_test`.`user_onboarding_message` +ADD UNIQUE INDEX `ux_user_onboarding_message_user_message` (`user_id`, `onboarding_message_id`); 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 000000000..11acdd4ba --- /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 000000000..00ac56611 --- /dev/null +++ b/tools/migrations/26-05-03_add_user_onboarding_message_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE `zeeguu_test`.`user_onboarding_message` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NULL, + `onboarding_message_id` INT NULL, + `message_shown_time` DATETIME NULL, + `message_click_time` DATETIME NULL, + PRIMARY KEY (`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 3842d7886..220ecccf0 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 000000000..f1f38b389 --- /dev/null +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -0,0 +1,73 @@ +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. + """ + user = User.find_by_id(flask.g.user_id) + 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) + + return json_result( + { + "shown": UserOnboardingMessage.has_message_shown_time( + user.id, int(onboarding_message_id) + ) + } + ) + + +@api.route("/get_onboarding_message_for_user", methods=["POST"]) +@cross_domain +@requires_session +def get_onboarding_message_for_user(): + """ + Records that an onboarding message was shown to the user. + Frontend calls this when the message appears on screen. + Uses the find_or_create pattern: row was pre-created for user, + now just mark when it was shown. + """ + user = User.find_by_id(flask.g.user_id) + 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) + + user_onboarding_message = UserOnboardingMessage.find_or_create_for_user_and_message( + db_session, user.id, int(onboarding_message_id) + ) + 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 + # user = User.find_by_id(flask.g.user_id) + user_onboarding_message_id = data.get("user_onboarding_message_id", None) + 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/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index cc883dc97..79084a51a 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -7,6 +7,7 @@ from zeeguu.core.emailer.email_confirmation import send_email_confirmation from zeeguu.core.model import Cohort, User, Teacher, Language, UserLanguage from zeeguu.core.model.unique_code import UniqueCode +from zeeguu.core.model.user_onboarding_message import UserOnboardingMessage from zeeguu.logging import log @@ -163,6 +164,11 @@ def add_siblings(user): db_session.add(user_language) if cohort and cohort.is_cohort_of_teachers: db_session.add(Teacher(user)) + # Initialize onboarding messages for the new user (idempotent) + for msg_id in range(1, 8): + UserOnboardingMessage.find_or_create_for_user_and_message( + db_session, user.id, msg_id + ) new_user, animal = _commit_new_user_with_retry(db_session, build_user, add_siblings) @@ -235,6 +241,11 @@ def build_user(): def add_siblings(user): if cohort and cohort.is_cohort_of_teachers: db_session.add(Teacher(user)) + # Initialize onboarding messages for the new user (idempotent) + for msg_id in range(1, 8): + UserOnboardingMessage.find_or_create_for_user_and_message( + db_session, user.id, msg_id + ) new_user, animal = _commit_new_user_with_retry(db_session, build_user, add_siblings) diff --git a/zeeguu/core/model/__init__.py b/zeeguu/core/model/__init__.py index c71912f47..7aa03962f 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 000000000..240ce911f --- /dev/null +++ b/zeeguu/core/model/onboarding_message.py @@ -0,0 +1,45 @@ +import sqlalchemy +from sqlalchemy import Column, Integer + +from zeeguu.core.model.db import db + +class OnboardingMessage(db.Model): + """ + OnboardingMessage reflects the different types of onbaording messages + that the system is able to output. + + """ + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True) + type = db.Column(db.String) + + 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: + onboardingMessage = cls.query.filter(cls.id == i).one() + return onboardingMessage + except Exception as e: + from sentry_sdk import capture_exception + + capture_exception(e) + return None \ No newline at end of file diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py new file mode 100644 index 000000000..3cd945022 --- /dev/null +++ b/zeeguu/core/model/user_onboarding_message.py @@ -0,0 +1,97 @@ +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: + onboarding_message = cls.query.filter(cls.id == i).one() + return onboarding_message + except Exception as e: + from sentry_sdk import capture_exception + + capture_exception(e) + return None + + @classmethod + def get_all_onboarding_messages_for_user(cls, user_id): + try: + user_onboarding_message = cls.query.filter(cls.user_id == user_id).all() + return user_onboarding_message + except sqlalchemy.orm.exc.NoResultFound: + return None + + @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 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) + 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) + session.commit() + log("Created new user onboarding message record") + return new_record + \ No newline at end of file From 381a6ab13e86d4cde0dc126c66b8eaa748dfb76a Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:04:12 +0200 Subject: [PATCH 02/10] get_onboarding_message_status issue is fixed --- zeeguu/core/model/user_onboarding_message.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 3cd945022..85701822d 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -70,6 +70,17 @@ def find_by_user_and_message(cls, user_id, onboarding_message_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.""" From aa4ce4dc87c1bf7a6079112ad4f955296dbe9b1f Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:12:48 +0200 Subject: [PATCH 03/10] fixed the authorization bug in set_onboarding_message_click_time --- zeeguu/api/endpoints/user_onboarding_message.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/zeeguu/api/endpoints/user_onboarding_message.py b/zeeguu/api/endpoints/user_onboarding_message.py index f1f38b389..95860ce59 100644 --- a/zeeguu/api/endpoints/user_onboarding_message.py +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -65,9 +65,20 @@ def get_onboarding_message_for_user(): @requires_session def set_onboarding_message_click_time(): data = flask.request.form - # user = User.find_by_id(flask.g.user_id) - user_onboarding_message_id = data.get("user_onboarding_message_id", None) - UserOnboardingMessage.update_user_onboarding_message_time(user_onboarding_message_id, db_session) + onboarding_message_id = data.get("onboarding_message_id", None) + + if not onboarding_message_id: + return json_result({"error": "onboarding_message_id required"}, status=400) + + + user_onboarding_message = UserOnboardingMessage.find_by_user_and_message( + flask.g.user_id, int(onboarding_message_id) + ) + + 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 From e74f4dab22301228a6b493273d521456e880b403 Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:15:34 +0200 Subject: [PATCH 04/10] added null check for update_user_onboarding_message_time --- zeeguu/core/model/user_onboarding_message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 85701822d..8dd00faca 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -85,6 +85,9 @@ def has_message_shown_time(cls, user_id, onboarding_message_id): 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 From cac48aebd992c8b3f0ef8f7e37834910e3f9019d Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:23:26 +0200 Subject: [PATCH 05/10] deleted commit() from the user_onboarding_message.py --- zeeguu/core/model/user_onboarding_message.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 8dd00faca..1c73b290c 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -105,7 +105,6 @@ def find_or_create_for_user_and_message(cls, session, user_id, onboarding_messag new_record = cls(user_id, onboarding_message_id) session.add(new_record) - session.commit() - log("Created new user onboarding message record") + log("Created new user onboarding message record (pending commit)") return new_record \ No newline at end of file From c0b9bb007b175214169255aeab6569e170838c9a Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:24:19 +0200 Subject: [PATCH 06/10] changed the name of the route for user_onboarding_message.py --- zeeguu/api/endpoints/user_onboarding_message.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/user_onboarding_message.py b/zeeguu/api/endpoints/user_onboarding_message.py index 95860ce59..2b3a1ee76 100644 --- a/zeeguu/api/endpoints/user_onboarding_message.py +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -29,10 +29,10 @@ def get_onboarding_message_status(): ) -@api.route("/get_onboarding_message_for_user", methods=["POST"]) +@api.route("/mark_onboarding_message_shown", methods=["POST"]) # canonical name @cross_domain @requires_session -def get_onboarding_message_for_user(): +def mark_onboarding_message_shown(): """ Records that an onboarding message was shown to the user. Frontend calls this when the message appears on screen. @@ -51,12 +51,12 @@ def get_onboarding_message_for_user(): ) 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) + "onboarding_message_id": int(onboarding_message_id), } - + return json_result(onboarding_data) From 701fe0b4a2314975b6007ef47cdf4a9c7b93b707 Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 16:39:56 +0200 Subject: [PATCH 07/10] clean up --- ...6-05-03--add_onboarding_message_table.sql} | 0 ...03--add_user_onboarding_message_table.sql} | 1 + ...r_onboarding_message_unique_constraint.sql | 3 -- .../api/endpoints/user_onboarding_message.py | 36 ++++++++++--------- zeeguu/core/model/onboarding_message.py | 14 ++++---- zeeguu/core/model/user_onboarding_message.py | 7 +--- 6 files changed, 29 insertions(+), 32 deletions(-) rename tools/migrations/{26-05-03_add_onboarding_message_table.sql => 26-05-03--add_onboarding_message_table.sql} (100%) rename tools/migrations/{26-05-03_add_user_onboarding_message_table.sql => 26-05-03--add_user_onboarding_message_table.sql} (88%) delete mode 100644 tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql diff --git a/tools/migrations/26-05-03_add_onboarding_message_table.sql b/tools/migrations/26-05-03--add_onboarding_message_table.sql similarity index 100% rename from tools/migrations/26-05-03_add_onboarding_message_table.sql rename to tools/migrations/26-05-03--add_onboarding_message_table.sql 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 similarity index 88% rename from tools/migrations/26-05-03_add_user_onboarding_message_table.sql rename to tools/migrations/26-05-03--add_user_onboarding_message_table.sql index 00ac56611..6e03a6751 100644 --- a/tools/migrations/26-05-03_add_user_onboarding_message_table.sql +++ b/tools/migrations/26-05-03--add_user_onboarding_message_table.sql @@ -5,6 +5,7 @@ CREATE TABLE `zeeguu_test`.`user_onboarding_message` ( `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, diff --git a/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql b/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql deleted file mode 100644 index e5d55095e..000000000 --- a/tools/migrations/26-05-03--add_user_onboarding_message_unique_constraint.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add unique constraint to ensure one record per user/message -ALTER TABLE `zeeguu_test`.`user_onboarding_message` -ADD UNIQUE INDEX `ux_user_onboarding_message_user_message` (`user_id`, `onboarding_message_id`); diff --git a/zeeguu/api/endpoints/user_onboarding_message.py b/zeeguu/api/endpoints/user_onboarding_message.py index 2b3a1ee76..659bb6103 100644 --- a/zeeguu/api/endpoints/user_onboarding_message.py +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -4,7 +4,7 @@ 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 +from . import api, db_session @api.route("/get_onboarding_message_status", methods=["GET"]) @cross_domain @@ -14,19 +14,17 @@ 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. """ - user = User.find_by_id(flask.g.user_id) 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) - return json_result( - { - "shown": UserOnboardingMessage.has_message_shown_time( - user.id, int(onboarding_message_id) - ) - } - ) + 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 @@ -39,15 +37,19 @@ def mark_onboarding_message_shown(): Uses the find_or_create pattern: row was pre-created for user, now just mark when it was shown. """ - user = User.find_by_id(flask.g.user_id) 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, user.id, int(onboarding_message_id) + db_session, flask.g.user_id, mid ) UserOnboardingMessage.set_message_shown_time(user_onboarding_message.id, db_session) db_session.commit() @@ -70,10 +72,12 @@ def set_onboarding_message_click_time(): 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, int(onboarding_message_id) - ) + 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) diff --git a/zeeguu/core/model/onboarding_message.py b/zeeguu/core/model/onboarding_message.py index 240ce911f..470de59ac 100644 --- a/zeeguu/core/model/onboarding_message.py +++ b/zeeguu/core/model/onboarding_message.py @@ -1,18 +1,17 @@ import sqlalchemy -from sqlalchemy import Column, Integer from zeeguu.core.model.db import db class OnboardingMessage(db.Model): """ - OnboardingMessage reflects the different types of onbaording messages + OnboardingMessage reflects the different types of onboarding messages that the system is able to output. """ __table_args__ = {"mysql_collate": "utf8_bin"} - id = Column(Integer, primary_key=True) - type = db.Column(db.String) + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(45)) TRANSLATE_MSG = 1 UNSELECT_MSG = 2 @@ -36,10 +35,11 @@ def find(cls, type): @classmethod def find_by_id(cls, i): try: - onboardingMessage = cls.query.filter(cls.id == i).one() - return onboardingMessage + 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) - return None \ No newline at end of file + raise diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 1c73b290c..b904d8e82 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -42,11 +42,7 @@ def find_by_id(cls, i): @classmethod def get_all_onboarding_messages_for_user(cls, user_id): - try: - user_onboarding_message = cls.query.filter(cls.user_id == user_id).all() - return user_onboarding_message - except sqlalchemy.orm.exc.NoResultFound: - return None + return cls.query.filter(cls.user_id == user_id).all() @classmethod def create_user_onboarding_message(cls, user_id, onboarding_message_id, db_session): @@ -107,4 +103,3 @@ def find_or_create_for_user_and_message(cls, session, user_id, onboarding_messag session.add(new_record) log("Created new user onboarding message record (pending commit)") return new_record - \ No newline at end of file From 3eafc157d15670ddac5d5888b2b2b0a10cd12fd4 Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 17:20:51 +0200 Subject: [PATCH 08/10] Deleted pre-creating 7 rows per user at signup now the rows user-message are created when the message is shown and the frontend should check whether there is already a row with this messageID for this user before showing the message --- .../26-05-03--add_user_onboarding_message_table.sql | 4 ++-- zeeguu/api/endpoints/user_onboarding_message.py | 4 ++-- .../core/account_management/user_account_creation.py | 11 ----------- zeeguu/core/model/user_onboarding_message.py | 12 ++++++++++-- 4 files changed, 14 insertions(+), 17 deletions(-) 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 index 6e03a6751..92f544f19 100644 --- a/tools/migrations/26-05-03--add_user_onboarding_message_table.sql +++ b/tools/migrations/26-05-03--add_user_onboarding_message_table.sql @@ -1,7 +1,7 @@ CREATE TABLE `zeeguu_test`.`user_onboarding_message` ( `id` INT NOT NULL AUTO_INCREMENT, - `user_id` INT NULL, - `onboarding_message_id` INT NULL, + `user_id` INT NOT NULL, + `onboarding_message_id` INT NOT NULL, `message_shown_time` DATETIME NULL, `message_click_time` DATETIME NULL, PRIMARY KEY (`id`), diff --git a/zeeguu/api/endpoints/user_onboarding_message.py b/zeeguu/api/endpoints/user_onboarding_message.py index 659bb6103..fbc6d719f 100644 --- a/zeeguu/api/endpoints/user_onboarding_message.py +++ b/zeeguu/api/endpoints/user_onboarding_message.py @@ -34,8 +34,8 @@ 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 the find_or_create pattern: row was pre-created for user, - now just mark when it was shown. + 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) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 79084a51a..cc883dc97 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -7,7 +7,6 @@ from zeeguu.core.emailer.email_confirmation import send_email_confirmation from zeeguu.core.model import Cohort, User, Teacher, Language, UserLanguage from zeeguu.core.model.unique_code import UniqueCode -from zeeguu.core.model.user_onboarding_message import UserOnboardingMessage from zeeguu.logging import log @@ -164,11 +163,6 @@ def add_siblings(user): db_session.add(user_language) if cohort and cohort.is_cohort_of_teachers: db_session.add(Teacher(user)) - # Initialize onboarding messages for the new user (idempotent) - for msg_id in range(1, 8): - UserOnboardingMessage.find_or_create_for_user_and_message( - db_session, user.id, msg_id - ) new_user, animal = _commit_new_user_with_retry(db_session, build_user, add_siblings) @@ -241,11 +235,6 @@ def build_user(): def add_siblings(user): if cohort and cohort.is_cohort_of_teachers: db_session.add(Teacher(user)) - # Initialize onboarding messages for the new user (idempotent) - for msg_id in range(1, 8): - UserOnboardingMessage.find_or_create_for_user_and_message( - db_session, user.id, msg_id - ) new_user, animal = _commit_new_user_with_retry(db_session, build_user, add_siblings) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index b904d8e82..3360d8f22 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -101,5 +101,13 @@ def find_or_create_for_user_and_message(cls, session, user_id, onboarding_messag new_record = cls(user_id, onboarding_message_id) session.add(new_record) - log("Created new user onboarding message record (pending commit)") - return 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() From 4b66c503170f23c4f4e3d000fb2699f4edfd13c1 Mon Sep 17 00:00:00 2001 From: Anna Semeriuk Date: Tue, 5 May 2026 18:58:01 +0200 Subject: [PATCH 09/10] tests for onboarding --- zeeguu/api/test/test_onboarding_message.py | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 zeeguu/api/test/test_onboarding_message.py diff --git a/zeeguu/api/test/test_onboarding_message.py b/zeeguu/api/test/test_onboarding_message.py new file mode 100644 index 000000000..f69f28163 --- /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 From 119a187b6a03c049b05dac2fc3d9bf4dd73dd3ff Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Thu, 7 May 2026 09:20:34 +0200 Subject: [PATCH 10/10] Don't swallow non-NoResultFound errors in UserOnboardingMessage.find_by_id Mirrors the fix already applied to OnboardingMessage.find_by_id in 701fe0b4: catch NoResultFound -> None, but let real DB errors propagate (after reporting to Sentry) instead of returning None and confusing callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- zeeguu/core/model/user_onboarding_message.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 3360d8f22..0f56d9522 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -32,13 +32,14 @@ def __repr__(self): @classmethod def find_by_id(cls, i): try: - onboarding_message = cls.query.filter(cls.id == i).one() - return onboarding_message + 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) - return None + raise @classmethod def get_all_onboarding_messages_for_user(cls, user_id):