Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
08cae1f
Working on friend streak feature
xXPinkmagicXx Mar 11, 2026
60d65f3
working on testing friends streak
xXPinkmagicXx Mar 11, 2026
ccba5c8
Merge branch 'gamification' into feature/friend-streak
xXPinkmagicXx Mar 11, 2026
50f1985
Merge branch 'feature/friend-streak' of https://github.com/zeeguu/api…
xXPinkmagicXx Mar 11, 2026
0ecb0a6
Working on the friends streak functinoality
xXPinkmagicXx Mar 11, 2026
b069f55
Update friend streak implemented
xXPinkmagicXx Mar 11, 2026
be08968
Always run the update_friends_streak
xXPinkmagicXx Mar 11, 2026
2603bb5
Added created_at in the return value for get_user_details()
klnyzzz33 Mar 12, 2026
b690d6e
Merge branch 'gamification' into feature/profile
klnyzzz33 Mar 12, 2026
a6389ab
working on get friends and friend_streak
xXPinkmagicXx Mar 12, 2026
a02b75d
Added friend_streak and last_updated to the get_friends endpoint
xXPinkmagicXx Mar 12, 2026
126ef9d
same response for search users and get_friends
xXPinkmagicXx Mar 12, 2026
705f983
Added endpoint for fetching daily streak information for all active l…
klnyzzz33 Mar 12, 2026
85ec2ff
updated the friend_streak_logic
xXPinkmagicXx Mar 12, 2026
5df8962
use local time instead of UTC (for now)
xXPinkmagicXx Mar 12, 2026
3e716bb
Get friend details
xXPinkmagicXx Mar 12, 2026
5d19b45
Added user avatars and returning them on /get_user_details
klnyzzz33 Mar 13, 2026
6833e29
Username now also gets saved on /user_settings
klnyzzz33 Mar 13, 2026
f9d6a5f
Added avatar saving for users
gabortodor Mar 14, 2026
5c1dc98
Merge branch 'gamification' into feature/profile
gabortodor Mar 14, 2026
f0228b0
User avatar saving refactor
klnyzzz33 Mar 14, 2026
d00bf86
Fixed friendship migration script
gabortodor Mar 15, 2026
b484825
Minor badge improvements
gabortodor Mar 15, 2026
1412d02
Update friend streak and db session logic
xXPinkmagicXx Mar 17, 2026
8a9f25b
try to fix broken env in pipeline
xXPinkmagicXx Mar 17, 2026
281add9
fix tests
xXPinkmagicXx Mar 17, 2026
d999f4a
Merge pull request #503 from zeeguu/feature/friend-streak
xXPinkmagicXx Mar 17, 2026
f191830
added friend streak to the migration
xXPinkmagicXx Mar 17, 2026
985913c
added tests for the get_user_details for friends
xXPinkmagicXx Mar 17, 2026
759e356
Added created_at in the return value for get_user_details()
klnyzzz33 Mar 12, 2026
e18d5b6
Added endpoint for fetching daily streak information for all active l…
klnyzzz33 Mar 12, 2026
9ca81a7
Added user avatars and returning them on /get_user_details
klnyzzz33 Mar 13, 2026
7e1b29d
Username now also gets saved on /user_settings
klnyzzz33 Mar 13, 2026
16d1757
Added avatar saving for users
gabortodor Mar 14, 2026
a1b2941
Hide non-simplified articles from recommendations
mircealungu Mar 13, 2026
d445ab9
Allow setting password via /user_settings endpoint
mircealungu Mar 13, 2026
080028f
Add atomic account upgrade to avoid email verification limbo state
mircealungu Mar 13, 2026
6b38b55
Add @allows_unverified to daily_streak endpoint
mircealungu Mar 13, 2026
8cac124
Revert @allows_unverified from daily_streak, user_settings, user_pref…
mircealungu Mar 13, 2026
cd42623
Simplify verification codes to 4-digit numeric
mircealungu Mar 13, 2026
45a112d
Fix starred/liked articles not showing due to recommendation filter
mircealungu Mar 13, 2026
21ae2ff
Add migration to fix exercise_report reason ENUM missing wrong_highli…
mircealungu Mar 13, 2026
d35a8bd
Filter hidden articles from unfinished reading sessions
mircealungu Mar 13, 2026
dd1db86
User avatar saving refactor
klnyzzz33 Mar 14, 2026
590c5a6
Fixed friendship migration script
gabortodor Mar 15, 2026
3e2f61b
Minor badge improvements
gabortodor Mar 15, 2026
e16ce7d
updated the friend_streak_logic
xXPinkmagicXx Mar 12, 2026
c27da60
use local time instead of UTC (for now)
xXPinkmagicXx Mar 12, 2026
1e1b889
Update friend streak and db session logic
xXPinkmagicXx Mar 17, 2026
9179b4b
try to fix broken env in pipeline
xXPinkmagicXx Mar 17, 2026
70735f7
fix tests
xXPinkmagicXx Mar 17, 2026
9fe62cb
Working on get data for see friend user profile
xXPinkmagicXx Mar 17, 2026
93fce8c
Exclude yourslef as as a friend
xXPinkmagicXx Mar 17, 2026
d3b227c
Working on see friend / user profile
xXPinkmagicXx Mar 17, 2026
d071bcb
return friendship info in the get_user_details
xXPinkmagicXx Mar 17, 2026
3d17d29
Added avatar saving for users
gabortodor Mar 14, 2026
307d8cd
Allow setting password via /user_settings endpoint
mircealungu Mar 13, 2026
6bd99fd
Add atomic account upgrade to avoid email verification limbo state
mircealungu Mar 13, 2026
ab089db
Add @allows_unverified to daily_streak endpoint
mircealungu Mar 13, 2026
8526f2f
Revert @allows_unverified from daily_streak, user_settings, user_pref…
mircealungu Mar 13, 2026
dd79075
User avatar saving refactor
klnyzzz33 Mar 14, 2026
854d5cc
Update friend streak and db session logic
xXPinkmagicXx Mar 17, 2026
685a762
fix tests
xXPinkmagicXx Mar 17, 2026
e60323b
Working on friendship and language
xXPinkmagicXx Mar 18, 2026
f4dd1d8
Merge branch 'feature/profile' into feature/see-friend-profile
gabortodor Mar 18, 2026
1deb9ba
Merge pull request #506 from zeeguu/feature/see-friend-profile
gabortodor Mar 18, 2026
8b054cf
the user language info
xXPinkmagicXx Mar 18, 2026
12123d4
Modified get_badges_for_user return value
klnyzzz33 Mar 19, 2026
8eb6cdf
Extended get_all_daily_streak for friend profiles as well
klnyzzz33 Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,18 @@ jobs:
echo "ZEEGUU_RESOURCES_FOLDER=$ZEEGUU_RESOURCES_FOLDER" >> $GITHUB_ENV
echo $ZEEGUU_RESOURCES_FOLDER

# Create and activate virtual environment
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
# Create or rebuild virtual environment when cache is stale/broken.
REBUILD_VENV=0
if [ ! -x "venv/bin/python" ] || ! venv/bin/python -V > /dev/null 2>&1; then
echo "Creating or rebuilding virtual environment..."
rm -rf venv
python -m venv venv
REBUILD_VENV=1
fi
source venv/bin/activate

# Only install if cache missed or requirements changed
if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ]; then
# Install when cache missed, requirements changed, or venv was rebuilt.
if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ] || [ "$REBUILD_VENV" -eq 1 ]; then
echo "Installing Python dependencies..."
python -m pip install --upgrade pip
pip install -r requirements.txt
Expand Down Expand Up @@ -85,4 +88,4 @@ jobs:
source venv/bin/activate
export NLTK_DATA=$ZEEGUU_RESOURCES_FOLDER/nltk_data
export DEV_SKIP_TRANSLATION=1
pytest
python -m pytest
2 changes: 1 addition & 1 deletion tools/migrations/26-02-24-a-add_username.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ ALTER TABLE user
ADD COLUMN username VARCHAR(50);

-- This is maybe needed
-- SET SQL_SAFE_UPDATES = 0;
SET SQL_SAFE_UPDATES = 0;

-- Option 1 user_<id>
UPDATE user
Expand Down
10 changes: 6 additions & 4 deletions tools/migrations/26-02-24-friendship_system.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ CREATE TABLE friends (
user_id INT NOT NULL,
friend_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
friend_streak INT DEFAULT 0,
friend_streak_last_updated DATETIME,
CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id),
CONSTRAINT unique_friend_user UNIQUE (friend_id, user_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (friend_id) REFERENCES users(id)
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (friend_id) REFERENCES user(id)
);

-- Friend requests table
Expand All @@ -18,6 +20,6 @@ CREATE TABLE friend_requests (
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME,
FOREIGN KEY (sender_id) REFERENCES users(id),
FOREIGN KEY (receiver_id) REFERENCES users(id)
FOREIGN KEY (sender_id) REFERENCES user(id),
FOREIGN KEY (receiver_id) REFERENCES user(id)
);
9 changes: 9 additions & 0 deletions tools/migrations/26-03-13--add_user_avatar.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE user_avatar (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
image_name VARCHAR(100),
character_color VARCHAR(7),
background_color VARCHAR(7),
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES user(id)
);
19 changes: 12 additions & 7 deletions zeeguu/api/endpoints/badges.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

from zeeguu.core.model.user_badge_progress import UserBadgeProgress
from zeeguu.core.model.badge_level import BadgeLevel
from zeeguu.api.utils.abort_handling import make_error
from zeeguu.api.utils.json_result import json_result
from zeeguu.api.utils.route_wrappers import cross_domain, requires_session
from zeeguu.core.model.badge import Badge
from zeeguu.core.model.friend import Friend
from zeeguu.core.model.user_badge_level import UserBadgeLevel
from . import api, db_session

Expand All @@ -25,18 +27,18 @@ def get_not_shown_user_badge_levels():

# ---------------------------------------------------------------------------
@api.route("/badges", methods=["GET"])
@api.route("/badges/<int:user_id>", methods=["GET"])
# ---------------------------------------------------------------------------
@cross_domain
@requires_session
def get_badges_for_user():
def get_badges_for_user(user_id: int = None):
"""
Retrieve all badges and their levels for the current user.
Retrieve all badges and their levels for the specified or current user.
Each badge level includes achievement status and whether it has been shown.

Returns:
[
{
"badge_id": 1,
"name": "Meaning Builder",
"description": "Translate {target_value} words while reading.",
"levels": [
Expand All @@ -52,12 +54,16 @@ def get_badges_for_user():
"current_value": 10
}, ... ]
"""
user_id = flask.g.user_id
requester_id = flask.g.user_id
used_user_id = user_id if user_id is not None else requester_id

if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id):
return make_error(403, "You can only view badges for yourself or your friends.")

badges = Badge.query.options(joinedload(Badge.badge_levels)).all()
user_badge_levels = UserBadgeLevel.find_all(user_id)
user_badge_levels = UserBadgeLevel.find_all(used_user_id)
achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels}
user_badge_progress = UserBadgeProgress.find_all(user_id)
user_badge_progress = UserBadgeProgress.find_all(used_user_id)
progress_map = {ubp.badge_id: ubp for ubp in user_badge_progress}

result = [serialize_badge(badge, achieved_map, progress_map) for badge in badges]
Expand Down Expand Up @@ -96,7 +102,6 @@ def serialize_badge(badge: Badge, achieved_map: dict, progress_map: dict) -> dic
]

return {
"badge_id": badge.id,
"name": badge.name,
"description": badge.description,
"levels": levels,
Expand Down
27 changes: 27 additions & 0 deletions zeeguu/api/endpoints/daily_streak.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import flask

from zeeguu.core.model.friend import Friend
from zeeguu.api.utils.json_result import json_result
from zeeguu.api.utils.route_wrappers import cross_domain, requires_session
from . import api
Expand All @@ -19,3 +20,29 @@ def get_daily_streak():
"max_streak": user_language.max_streak or 0,
"max_streak_date": user_language.max_streak_date.strftime("%Y-%m-%d") if user_language.max_streak_date else None,
})


@api.route("/all_daily_streak", methods=["GET"])
@api.route("/all_daily_streak/<int:user_id>", methods=["GET"])
@cross_domain
@requires_session
def get_all_daily_streak(user_id: int = None):
requester_user_id = flask.g.user_id
requested_user_id = user_id if user_id is not None else requester_user_id

user = User.find_by_id(requested_user_id)
user_languages = UserLanguage.all_user_languages_for_user(user)
result = []
for user_language in user_languages:
obj = {
"language": user_language.language.as_dictionary(),
}
if requester_user_id == requested_user_id or Friend.are_friends(requester_user_id, requested_user_id):
obj.update({
"daily_streak": user_language.daily_streak or 0,
"max_streak": user_language.max_streak or 0,
"max_streak_date": user_language.max_streak_date.strftime(
"%Y-%m-%d") if user_language.max_streak_date else None
})
result.append(obj)
return json_result(result)
71 changes: 55 additions & 16 deletions zeeguu/api/endpoints/friends.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
import flask
from flask import request
from zeeguu.core.model import User
from zeeguu.core.model.friend import Friend
from zeeguu.core.model.friend_request import FriendRequest
from zeeguu.api.utils.json_result import json_result
from sqlalchemy.orm.exc import NoResultFound

from zeeguu.api.utils.abort_handling import make_error
from zeeguu.api.utils.json_result import json_result
from zeeguu.api.utils.route_wrappers import cross_domain, requires_session
from zeeguu.logging import log, debug, warning, critical
from zeeguu.core.model import User
from zeeguu.core.model.friend import Friend
from zeeguu.core.model.friend_request import FriendRequest
from zeeguu.logging import log, warning
from . import api


# ---------------------------------------------------------------------------
@api.route("/get_friends", methods=["GET"])
@api.route("/get_friends/<int:user_id>", methods=["GET"])
# ---------------------------------------------------------------------------
@cross_domain
@requires_session
def get_friends():
def get_friends(user_id: int = None):
"""
Get all friends of current user with flask.g.user_id
Get all friends for the current user, or for a friend by user id.
"""
friends = Friend.get_friends(flask.g.user_id)
result = _serialize_users(friends)
log(f"get_friends: user_id={flask.g.user_id} has {len(result)} friends")
requester_id = flask.g.user_id
used_user_id = user_id if user_id is not None else requester_id

if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id):
return make_error(403, "You can only view friends for yourself or your friends.")

exclude_id = requester_id if used_user_id != requester_id else None
friends_with_friendships = Friend.get_friends_with_friendship(used_user_id, exclude_user_id=exclude_id)
result = [
_serialize_user_with_friendship(entry["user"], entry["friendship"])
for entry in friends_with_friendships
]
log(f"get_friends: requester_id={requester_id} requested friends for user_id={used_user_id}; count={len(result)}")
return json_result(result)

def _serialize_user_with_friendship(user: User, friendship):
user_data = _serialize_user(user)
if not isinstance(user_data, dict):
warning(
f"_serialize_user_with_friendship: expected dict from _serialize_user, got {type(user_data)}"
)
user_data = {}

user_data["friendship"] = _serialize_friendship(friendship) if friendship else None
user_data["languages"] = _serialize_user_languages(user) if user else []
return user_data

# ---------------------------------------------------------------------------
@api.route("/get_friend_requests", methods=["GET"])
Expand Down Expand Up @@ -237,15 +261,30 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"):
"receiver_id": friendship.friend_id,
"created_at": friendship.created_at,
"friend_request_status": status,
"friend_streak": friendship.friend_streak,
"friend_streak_last_updated": friendship.friend_streak_last_updated.isoformat() if friendship.friend_streak_last_updated else None,
}

def _serialize_user(user: User):
return {
"id": user.id,
"name": user.name,
"username": user.username,
"email": user.email,
}
if user is None:
warning("_serialize_user: user is None")
return {}


result = user.details_as_dictionary() or {}
if not isinstance(result, dict):
warning(f"_serialize_user: details_as_dictionary returned {type(result)} for user_id={user.id}")
result = {}

result["id"] = user.id

return result

def _serialize_user_languages(user):
# Add all languages the user is learning
from zeeguu.core.model.user_language import UserLanguage
user_languages = UserLanguage.all_user_languages_for_user(user)
return [ul.language.as_dictionary() for ul in user_languages]

def _serialize_users(users: list[User]):
return [_serialize_user(user) for user in users]
Expand Down
57 changes: 55 additions & 2 deletions zeeguu/api/endpoints/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from zeeguu.api.endpoints.feature_toggles import features_for_user
from zeeguu.api.utils.json_result import json_result
from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified
from zeeguu.core.model.user_avatar import UserAvatar
from zeeguu.core.model import User
from zeeguu.core.model.feedback_component import FeedbackComponent
from zeeguu.core.model.url import Url
Expand Down Expand Up @@ -184,6 +185,21 @@ def get_user_details():

return json_result(details_dict)

@api.route("/get_user_details/<int:friend_user_id>", methods=["GET"])
@cross_domain
@requires_session
def get_friend_details(friend_user_id):
"""
Return user details for friend_user_id, including a 'friendship' object
with friend_request_status ('accepted', 'pending', or None).
"""
user = User.find_by_id(flask.g.user_id)
from zeeguu.core.model.friend import Friend
friend_details = Friend.find_friend_details(user.id, friend_user_id)
if not friend_details:
return flask.jsonify({"error": "Not friends with this user or user not found."})
return json_result(friend_details)


@api.route("/user_settings", methods=["POST"])
@cross_domain
Expand All @@ -192,14 +208,18 @@ def user_settings():
"""
:return: OK for success
"""

user_id = flask.g.user_id
data = flask.request.form
user = User.find_by_id(flask.g.user_id)
user = User.find_by_id(user_id)

submitted_name = data.get("name", None)
if submitted_name:
user.name = submitted_name

submitted_username = data.get("username", None)
if submitted_username:
user.username = submitted_username

submitted_native_language_code = data.get("native_language", None)
if submitted_native_language_code:
user.set_native_language(submitted_native_language_code)
Expand All @@ -220,6 +240,39 @@ def user_settings():
if submitted_password:
user.update_password(submitted_password)

submitted_avatar_image_name = data.get("avatar_image_name", None)
submitted_avatar_character_color = data.get("avatar_character_color", None)
submitted_avatar_background_color = data.get("avatar_background_color", None)
user_avatar = UserAvatar.update_or_create(user_id, submitted_avatar_image_name, submitted_avatar_character_color,
submitted_avatar_background_color)
if any([
submitted_avatar_image_name,
submitted_avatar_character_color,
submitted_avatar_background_color
]):
user_avatar = UserAvatar.find(user_id)

if not user_avatar:
user_avatar = UserAvatar(user_id,
submitted_avatar_image_name,
submitted_avatar_character_color,
submitted_avatar_background_color)
else:
if submitted_avatar_image_name:
user_avatar.image_name = submitted_avatar_image_name

if submitted_avatar_character_color:
user_avatar.character_color = submitted_avatar_character_color

if submitted_avatar_background_color:
user_avatar.background_color = submitted_avatar_background_color

zeeguu.core.model.db.session.add(user_avatar)

submitted_password = data.get("password", None)
if submitted_password:
user.update_password(submitted_password)

zeeguu.core.model.db.session.add(user)
zeeguu.core.model.db.session.commit()
return "OK"
Expand Down
Loading
Loading