Skip to content
15 changes: 15 additions & 0 deletions tools/migrations/26-05-03--add_onboarding_message_table.sql
Original file line number Diff line number Diff line change
@@ -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');
13 changes: 13 additions & 0 deletions tools/migrations/26-05-03--add_user_onboarding_message_table.sql
Original file line number Diff line number Diff line change
@@ -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
);
1 change: 1 addition & 0 deletions zeeguu/api/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
88 changes: 88 additions & 0 deletions zeeguu/api/endpoints/user_onboarding_message.py
Original file line number Diff line number Diff line change
@@ -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"
94 changes: 94 additions & 0 deletions zeeguu/api/test/test_onboarding_message.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions zeeguu/core/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions zeeguu/core/model/onboarding_message.py
Original file line number Diff line number Diff line change
@@ -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"<OnboardingMessage: {self.id} Type: {self.type}>"

@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
114 changes: 114 additions & 0 deletions zeeguu/core/model/user_onboarding_message.py
Original file line number Diff line number Diff line change
@@ -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"<UserOnboardingMessage({self.id}): User: {self.user_id}, OnboardingMessage: {self.onboarding_message_id}>"

@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()
Loading