diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37929d63..4d20dae0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,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 diff --git a/Dockerfile b/Dockerfile index 101ca132..022567b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,14 @@ COPY ./setup.py /Zeeguu-API/setup.py WORKDIR /Zeeguu-API +# RUN python -m pip install --upgrade pip setuptools # Install Python requirements with BuildKit cache mount # Cache persisted via buildkit-cache-dance action to GitHub Actions cache RUN --mount=type=cache,target=/root/.cache/pip \ echo "=== Pip cache before install ===" && \ ls -lah /root/.cache/pip 2>/dev/null || echo "Cache empty (first build)" && \ - python -m pip install -r requirements.txt && \ - python -m pip install gunicorn && \ + python -m pip install --default-timeout=300 -r requirements.txt && \ + python -m pip install --default-timeout=300 gunicorn && \ echo "=== Pip cache after install ===" && \ du -sh /root/.cache/pip diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql new file mode 100644 index 00000000..019733b8 --- /dev/null +++ b/tools/migrations/26-02-19--add_badges.sql @@ -0,0 +1,41 @@ +-- tools/migrations/26-02-19--add_badge_and_user_badge_tables.sql + +CREATE TABLE badge ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(100) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + UNIQUE(code) +); + +CREATE TABLE badge_level ( + id INT AUTO_INCREMENT PRIMARY KEY, + badge_id INT NOT NULL, + name VARCHAR(100), + level INT NOT NULL, + target_value INT NOT NULL, + icon_name VARCHAR(255), + UNIQUE(badge_id, level), + FOREIGN KEY (badge_id) REFERENCES badge(id) +); + +CREATE TABLE user_badge_level ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + badge_level_id INT NOT NULL, + achieved_at DATETIME DEFAULT NULL, + is_shown BOOLEAN DEFAULT FALSE, + UNIQUE(user_id, badge_level_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (badge_level_id) REFERENCES badge_level(id) +); + +CREATE TABLE user_badge_progress ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + badge_id INT NOT NULL, + current_value INT NOT NULL, + UNIQUE(user_id, badge_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (badge_id) REFERENCES badge(id) +); \ No newline at end of file diff --git a/tools/migrations/26-02-24-a-add_username.sql b/tools/migrations/26-02-24-a-add_username.sql new file mode 100644 index 00000000..557f061d --- /dev/null +++ b/tools/migrations/26-02-24-a-add_username.sql @@ -0,0 +1,21 @@ +ALTER TABLE user +ADD COLUMN username VARCHAR(50); + +-- This is maybe needed +SET SQL_SAFE_UPDATES = 0; + +-- Option 1 user_ +UPDATE user +SET username = CONCAT('user_', id) +WHERE id IS NOT NULL +AND username IS NULL; + +-- In that case remember to enable it again +SET SQL_SAFE_UPDATES = 1; + +-- Change the column to be not null and unique +ALTER TABLE user +MODIFY username VARCHAR(50) NOT NULL; + +ALTER TABLE user +ADD CONSTRAINT unique_username UNIQUE (username); \ No newline at end of file diff --git a/tools/migrations/26-02-24-b-add_username.py b/tools/migrations/26-02-24-b-add_username.py new file mode 100644 index 00000000..dff2c4db --- /dev/null +++ b/tools/migrations/26-02-24-b-add_username.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +Migration script to automatically populate usernames for existing users. + +The usernames are generated in the format 'adjective_noun1234' (e.g., 'brave_tiger5678') +and are guaranteed to be unique across the user base. +This script should be run after the database schema has been updated to include the new 'username' column in the 'user' table. + +26-02-24-a-add_username.sql should have been run first to add the 'username' column to the 'user' table. + +Run with: source ~/.venvs/z_env/bin/activate && python tools/migrations/26-02-24-b-add_username.py +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from zeeguu.core.model.user import User +from zeeguu.core.model import db + +def generate_unique_username(): + + while True: + username = User.generate_username() + exists = User.query.filter_by(username=username).first() + if not exists: + return username + +def populate_usernames(): + users : list[User] = User.query.all() + for user in users: + user.username = generate_unique_username() + db.session.commit() + + +if __name__ == "__main__": + + from zeeguu.api.app import create_app + from zeeguu.core.model import db + + app = create_app() + app.app_context().push() + + print("Starting random username population...") + populate_usernames() + print("Username population completed.") \ No newline at end of file diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql new file mode 100644 index 00000000..1952498d --- /dev/null +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -0,0 +1,25 @@ +-- friends table +CREATE TABLE friends ( + id INT AUTO_INCREMENT PRIMARY KEY, + 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 user(id), + FOREIGN KEY (friend_id) REFERENCES user(id) +); + +-- Friend requests table +CREATE TABLE friend_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + responded_at DATETIME, + FOREIGN KEY (sender_id) REFERENCES user(id), + FOREIGN KEY (receiver_id) REFERENCES user(id) +); \ No newline at end of file diff --git a/tools/migrations/26-02-26--add_max_streak_to_user_language.sql b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql new file mode 100644 index 00000000..8d96a32e --- /dev/null +++ b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql @@ -0,0 +1,10 @@ +-- Add max_streak tracking to user_language table +ALTER TABLE user_language +ADD COLUMN max_streak INT NOT NULL DEFAULT 0, +ADD COLUMN max_streak_date DATETIME NULL; + +-- Seed max_streak from current daily_streak for users with active streaks +UPDATE user_language +SET max_streak = daily_streak, + max_streak_date = last_practiced +WHERE daily_streak > 0; diff --git a/tools/migrations/26-02-28--insert_default_badges.sql b/tools/migrations/26-02-28--insert_default_badges.sql new file mode 100644 index 00000000..35b9858a --- /dev/null +++ b/tools/migrations/26-02-28--insert_default_badges.sql @@ -0,0 +1,62 @@ +-- tools/migrations/26-02-28--insert_default_badge.sql + +INSERT INTO badge (id, code, name, description) +VALUES + (1, 'TRANSLATED_WORDS', 'Meaning Builder', 'Translate {target_value} unique words while reading.'), + (2, 'CORRECT_EXERCISES', 'Practice Builder', 'Solve {target_value} exercises correctly.'), + (3, 'COMPLETED_AUDIO_LESSONS', 'Sound Scholar', 'Complete {target_value} audio lessons.'), + (4, 'STREAK_COUNT', 'Consistency Champion', 'Maintain a streak for {target_value} days.'), + (5, 'LEARNED_WORDS', 'Word Collector', 'Learn {target_value} new words.'), + (6, 'READ_ARTICLES', 'Active Reader', 'Read {target_value} articles.'), + (7, 'NUMBER_OF_FRIENDS', 'Influencer', 'Add {target_value} friends.'); + +INSERT INTO badge_level (id, badge_id, name, level, target_value, icon_name) +VALUES + -- Translated Words + (1, 1, '', 1, 10, NULL), + (2, 1, '', 2, 100, NULL), + (3, 1, '', 3, 500, NULL), + (4, 1, '', 4, 1000, NULL), + (5, 1, '', 5, 2500, NULL), + + -- Correct Exercises + (6, 2, '', 1, 10, NULL), + (7, 2, '', 2, 250, NULL), + (8, 2, '', 3, 1000, NULL), + (9, 2, '', 4, 5000, NULL), + (10, 2, '', 5, 20000, NULL), + + -- Completed Audio Lessons + (11, 3, '', 1, 1, NULL), + (12, 3, '', 2, 25, NULL), + (13, 3, '', 3, 50, NULL), + (14, 3, '', 4, 150, NULL), + (15, 3, '', 5, 300, NULL), + + -- Streak Count + (16, 4, '', 1, 7, NULL), + (17, 4, '', 2, 30, NULL), + (18, 4, '', 3, 90, NULL), + (19, 4, '', 4, 180, NULL), + (20, 4, '', 5, 365, NULL), + + -- Learned Words + (21, 5, '', 1, 1, NULL), + (22, 5, '', 2, 10, NULL), + (23, 5, '', 3, 50, NULL), + (24, 5, '', 4, 250, NULL), + (25, 5, '', 5, 750, NULL), + + -- Read Articles + (26, 6, '', 1, 5, NULL), + (27, 6, '', 2, 25, NULL), + (28, 6, '', 3, 100, NULL), + (29, 6, '', 4, 500, NULL), + (30, 6, '', 5, 1000, NULL), + + -- Number of Friends + (31, 7, '', 1, 1, NULL), + (32, 7, '', 2, 3, NULL), + (33, 7, '', 3, 5, NULL), + (34, 7, '', 4, 7, NULL), + (35, 7, '', 5, 10, NULL); diff --git a/tools/migrations/26-03-13--add_user_avatar.sql b/tools/migrations/26-03-13--add_user_avatar.sql new file mode 100644 index 00000000..9940400c --- /dev/null +++ b/tools/migrations/26-03-13--add_user_avatar.sql @@ -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) +); diff --git a/zeeguu/api/endpoints/__init__.py b/zeeguu/api/endpoints/__init__.py index 2c9ebb38..67ed17ec 100644 --- a/zeeguu/api/endpoints/__init__.py +++ b/zeeguu/api/endpoints/__init__.py @@ -48,3 +48,5 @@ from . import user_stats from . import session_history from . import daily_streak +from . import badges +from . import friends diff --git a/zeeguu/api/endpoints/activity_tracking.py b/zeeguu/api/endpoints/activity_tracking.py index 984dcd38..34b4edb8 100644 --- a/zeeguu/api/endpoints/activity_tracking.py +++ b/zeeguu/api/endpoints/activity_tracking.py @@ -1,12 +1,13 @@ import flask from flask import request + +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode +from zeeguu.core.model import UserActivityData, User from zeeguu.core.user_activity_hooks.article_interaction_hooks import ( distill_article_interactions, ) - from . import api, db_session -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from zeeguu.core.model import UserActivityData, User @api.route("/upload_user_activity_data", methods=["POST"]) @@ -102,10 +103,10 @@ def _check_and_notify_article_completion_on_scroll(user, form_data): # Debug: log the scroll data structure from zeeguu.logging import log log(f"[article_completion] Scroll data received: {scroll_data[:3] if isinstance(scroll_data, list) and len(scroll_data) > 3 else scroll_data}") - + # Use a more lenient approach or fallback to max percentage completion_percentage = find_last_reading_percentage(scroll_data, max_jump=50, max_total_update=80) - + # Fallback: if the algorithm returns 0 but we have scroll data, use max percentage if completion_percentage == 0 and scroll_data: max_percentage = max([point[1] for point in scroll_data if len(point) >= 2]) / 100.0 @@ -135,6 +136,9 @@ def _check_and_notify_article_completion_on_scroll(user, form_data): user_article.completed_at = datetime.now() + # Update READ_ARTICLES badge progress + increment_badge_progress(db_session, BadgeCode.READ_ARTICLES, user.id) + # Send notification if enabled from flask import current_app diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py new file mode 100644 index 00000000..09ba6b95 --- /dev/null +++ b/zeeguu/api/endpoints/badges.py @@ -0,0 +1,125 @@ +import flask +from sqlalchemy.orm import joinedload + +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 + + +# --------------------------------------------------------------------------- +@api.route("/badges/count_not_shown", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_not_shown_user_badge_levels(): + """ + Return the number of user badge levels that the current user has achieved + but have not yet been shown to them. + """ + return json_result(UserBadgeLevel.count_user_not_shown(flask.g.user_id)) + + +# --------------------------------------------------------------------------- +@api.route("/badges", methods=["GET"]) +@api.route("/badges/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_badges_for_user(user_id: int = None): + """ + 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: + [ + { + "name": "Meaning Builder", + "description": "Translate {target_value} words while reading.", + "levels": [ + { + "badge_level": 1, + "target_value": 50, + "icon_name": "/badge1.svg", + "achieved": true, + "achieved_at": "2026-03-03T12:34:56", + "is_shown": false, + "name": "Beginner" + }, ...] + "current_value": 10 + }, ... ] + """ + 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(used_user_id) + achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} + 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] + + return json_result(result) + +# --------------------------------------------------------------------------- +@api.route("/badges/update_not_shown", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def update_not_shown_user_badge_levels(): + """ + Mark all unseen badge levels for the current user as shown. + + This updates all UserBadgeLevel records where: + - user_id matches the current user + - is_shown is False + + Returns: + { + "updated": true + } + """ + UserBadgeLevel.update_not_shown_for_user(db_session, flask.g.user_id) + db_session.commit() + + return json_result({"updated": True}) + + +def serialize_badge(badge: Badge, achieved_map: dict, progress_map: dict) -> dict: + progress = progress_map.get(badge.id) + levels = [ + serialize_badge_level(level, achieved_map.get(level.id)) + for level in sorted(badge.badge_levels, key=lambda b: b.level) + ] + + return { + "name": badge.name, + "description": badge.description, + "levels": levels, + "current_value": progress.current_value if progress else 0, + } + + +def serialize_badge_level(level: BadgeLevel, user_level: UserBadgeLevel | None) -> dict: + return { + "badge_level": level.level, + "target_value": level.target_value, + "icon_name": level.icon_name, + "achieved": user_level is not None, + "achieved_at": ( + user_level.achieved_at.isoformat() + if user_level and user_level.achieved_at + else None + ), + "is_shown": user_level.is_shown if user_level else False, + "name": level.name, + } diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index d95bdbb9..66b38588 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -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 @@ -14,7 +15,37 @@ def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) - return json_result({"daily_streak": user_language.daily_streak or 0}) + return json_result({ + "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, + }) + + +@api.route("/all_daily_streak", methods=["GET"]) +@api.route("/all_daily_streak/", 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) @api.route("/all_language_streaks", methods=["GET"]) diff --git a/zeeguu/api/endpoints/exercises.py b/zeeguu/api/endpoints/exercises.py index dabb5b05..67d1c16b 100644 --- a/zeeguu/api/endpoints/exercises.py +++ b/zeeguu/api/endpoints/exercises.py @@ -6,7 +6,7 @@ from zeeguu.core.model.bookmark import Bookmark from zeeguu.core.model.user import User -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean from . import api, db_session @@ -212,7 +212,7 @@ def report_exercise_outcome(): user_word = UserWord.query.get(user_word_id) if not user_word: return "FAIL - UserWord not found" - + user_word.report_exercise_outcome( db_session, source, @@ -222,6 +222,9 @@ def report_exercise_outcome(): other_feedback, ) + # Update daily streak when user completes an exercise + update_user_streak() + return "OK" except Exception as e: traceback.print_exc() diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py new file mode 100644 index 00000000..cba5020f --- /dev/null +++ b/zeeguu/api/endpoints/friends.py @@ -0,0 +1,307 @@ +import flask +from flask import request +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.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/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_friends(user_id: int = None): + """ + Get all friends for the current user, or for a friend by 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 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"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_friend_requests(): + """ + Get all friend requests of a user + """ + + friendRequest = FriendRequest.get_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) + +# --------------------------------------------------------------------------- +@api.route("/get_pending_friend_requests", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_pending_friend_requests(): + """ + Get all pending friend requests of a user + """ + + friendRequest = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) + + +# --------------------------------------------------------------------------- +@api.route("/send_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def send_friend_request(): + """ + Send a friend request from sender (current user with flask.g.user_id) to receiver + """ + sender_id = flask.g.user_id + receiver_id = request.json.get("receiver_id") + + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") + return make_error(status_code, error_message) + + try: + friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) + response = _serialize_friend_request(friend_request) + return json_result(response) + except ValueError as e: + log(f"send_friend_request: error sending friend request from user_id={sender_id} to user_id={receiver_id} - {str(e)}") + return make_error(400, str(e)) + except NoResultFound: + log(f"send_friend_request: user not found for user_id={receiver_id}") + return make_error(404, "User not found") + + +# --------------------------------------------------------------------------- +@api.route("/delete_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def delete_friend_request(): + """ + Delete a friend request between sender and receiver + """ + sender_id = flask.g.user_id + receiver_id = request.json.get("receiver_id") + + status_code, error = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + log(f"delete_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(status_code, error) + + is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) + return json_result({"success": is_deleted}) + +# --------------------------------------------------------------------------- +@api.route("/accept_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def accept_friend_request(): + """ + Accept a friend request between sender and receiver, and create a friendship + """ + # current user is the receiver of the friend request + receiver_id = flask.g.user_id + sender_id = request.json.get("sender_id") + print(f"sender_id: {sender_id}") + status_code, error = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + log(f"accept_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(status_code, error) + + friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) + if friendship is None: + log(f"accept_friend_request: no friend request found from user_id={sender_id} to user_id={receiver_id}") + return make_error(404, "No friend request found to accept") + + response = _serialize_friendship(friendship) + return json_result(response) + +# --------------------------------------------------------------------------- +@api.route("/reject_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def reject_friend_request(): + """ + Reject a friend request between sender and receiver, and delete the friend request record in the database + """ + # current user is the receiver of the friend request + receiver_id = flask.g.user_id + sender_id = request.json.get("sender_id") + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + + if status_code >= 400: + log(f"reject_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") + return make_error(status_code, error_message) + + is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) + return json_result({"success": is_rejected}) + +# --------------------------------------------------------------------------- +@api.route("/unfriend", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def unfriend(): + """ + Unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database + """ + sender_id = flask.g.user_id + receiver_id = request.json.get("receiver_id") + + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + log(f"unfriend: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") + return make_error(status_code, error_message) + + # Remove the friendship record from the database + is_removed = Friend.remove_friendship(sender_id, receiver_id) + log(f"unfriend: user_id={sender_id} unfriended user_id={receiver_id} - success={is_removed}") + return json_result({"success": is_removed}) + + +# --------------------------------------------------------------------------- +# Search and discover friends endpoints below +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +@api.route("/search_users/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def search_by_username(username): + """ + Search for users with for the current user + """ + if not username or username.strip() == "": + log(f"search_users: empty username search from user_id={flask.g.user_id}") + return make_error(400, "Username cannot be empty") + + result = Friend.search_users(flask.g.user_id, username) + log(f"search_users: user_id={flask.g.user_id} searched for username='{username}' and found {len(result)} results") + return json_result(result) + + +# --------------------------------------------------------------------------- +# Helper functions below +# --------------------------------------------------------------------------- + +def _serialize_friend_request(fr: FriendRequest): + """ + Serialize a FriendRequest object into JSON-friendly dict. + + Args: + fr (FriendRequest): The friend request object + current_user_id (int): Optional, to simplify sender/receiver info + + Returns: + dict: JSON-serializable dictionary + """ + return { + "id": fr.id, + "sender": { + "id": fr.sender.id, # This is the user_id is that nesessary? + "name": fr.sender.name, # This will be updated to username + "username": fr.sender.username, # This will be updated to username + "email": fr.sender.email, # Is this relevant? + }, + "receiver": { + "id": fr.receiver.id, # This is the user_id is that nesessary? + "name": fr.receiver.name, # This will be updated to username + "username": fr.receiver.username, # This will be updated to username + "email": fr.receiver.email, # Is this relevant? + }, + "friend_request_status": fr.status, + "created_at": fr.created_at.isoformat() if fr.created_at else None, + "responded_at": fr.responded_at.isoformat() if fr.responded_at else None, + } + +def _serialize_friendship(friendship: Friend, status: str = "accepted"): + return { + "id": friendship.id, + "sender_id": friendship.user_id, + "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): + 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] + + +def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: + """ + :param sender_id: the user_id of the sender of the friend request + :param receiver_id: the user_id of the receiver of the friend request + Validate the friend request data, return (status_code, error_message) + + :return: (status_code, error_message) + """ + if sender_id is None or receiver_id is None: + return 422, "invalid data sender_id or/and receiver_id" + + if sender_id == receiver_id: + return 422, "cannot send friend request to yourself" + + return 200, "ok" \ No newline at end of file diff --git a/zeeguu/api/endpoints/listening_sessions.py b/zeeguu/api/endpoints/listening_sessions.py index 25706adc..fd1c5893 100644 --- a/zeeguu/api/endpoints/listening_sessions.py +++ b/zeeguu/api/endpoints/listening_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserListeningSession @@ -20,6 +20,10 @@ def listening_session_start(): session = UserListeningSession._create_new_session( db_session, flask.g.user_id, daily_audio_lesson_id, platform=platform ) + + # Update daily streak when user starts listening + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/reading_sessions.py b/zeeguu/api/endpoints/reading_sessions.py index 44181310..84ff24d4 100644 --- a/zeeguu/api/endpoints/reading_sessions.py +++ b/zeeguu/api/endpoints/reading_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserReadingSession from datetime import datetime @@ -26,6 +26,10 @@ def reading_session_start(): session = UserReadingSession(flask.g.user_id, article_id, datetime.now(), reading_source, platform) db_session.add(session) db_session.commit() + + # Update daily streak when user starts reading + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 5e1b6c12..fd607c9b 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -8,7 +8,7 @@ from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.core.translation_services.translator import ( get_next_results, contribute_trans, @@ -157,6 +157,12 @@ def get_one_translation(from_lang_code, to_lang_code): log( f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) + from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode + increment_badge_progress(db_session, BadgeCode.TRANSLATED_WORDS, user.id) + db_session.commit() + + # Update daily streak when user translates a word (active reading practice) + update_user_streak() return json_result( { diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index d2954e5d..da9f1ef2 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -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 @@ -184,6 +185,21 @@ def get_user_details(): return json_result(details_dict) +@api.route("/get_user_details/", 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 @@ -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) @@ -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" diff --git a/zeeguu/api/endpoints/user_languages.py b/zeeguu/api/endpoints/user_languages.py index 84ad12c5..92660c05 100644 --- a/zeeguu/api/endpoints/user_languages.py +++ b/zeeguu/api/endpoints/user_languages.py @@ -105,7 +105,7 @@ def get_user_languages(): """ all_user_languages = [] user = User.find_by_id(flask.g.user_id) - user_languages = UserLanguage.all_for_user(user) + user_languages = UserLanguage.all_languages_for_user(user) for lan in user_languages: all_user_languages.append(lan.as_dictionary()) return json_result(all_user_languages) diff --git a/zeeguu/api/test/fixtures.py b/zeeguu/api/test/fixtures.py index e7999fe7..c5a49565 100644 --- a/zeeguu/api/test/fixtures.py +++ b/zeeguu/api/test/fixtures.py @@ -41,12 +41,24 @@ def logged_in_teacher(app, _mock_web): class LoggedInClient: - def __init__(self, client): + + def __init__( + self, + client, + email="i@mir.lu", + password="test", + username="test", + learned_language="de"): + self.client = client # Creating a user and returning also the session - test_user_data = dict(password="test", username="test", learned_language="de") - self.email = "i@mir.lu" + test_user_data = dict( + username=username, + password=password, + learned_language=learned_language) + + self.email = email response = self.client.post(f"/add_user/{self.email}", data=test_user_data) assert response.status_code == 200 diff --git a/zeeguu/api/test/test_badges.py b/zeeguu/api/test/test_badges.py new file mode 100644 index 00000000..ba9ae08e --- /dev/null +++ b/zeeguu/api/test/test_badges.py @@ -0,0 +1,49 @@ +from fixtures import LoggedInClient, logged_in_client as client +from zeeguu.core.model import User + + +def test_get_badges_for_friend_user_id(client: LoggedInClient): + """ + Test /badges/ returns badge data when users are friends. + """ + other_email = "badges-friend@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="badges-friend", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + response = client.get(f"/badges/{other_user.id}") + + assert isinstance(response, list) + if response: + assert "badge_id" in response[0] + assert "levels" in response[0] + + +def test_get_badges_for_non_friend_user_denied(client: LoggedInClient): + """ + Test /badges/ denies access when users are not friends. + """ + stranger_email = "badges-private@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="badges-private", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/badges/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("message") == "You can only view badges for yourself or your friends." diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py new file mode 100644 index 00000000..7286c9c0 --- /dev/null +++ b/zeeguu/api/test/test_friends.py @@ -0,0 +1,299 @@ +from fixtures import LoggedInClient, logged_in_client as client +from fixtures import add_context_types, add_source_types, create_and_get_article +from zeeguu.core.model import User +import json + +def test_accept_friend_request_success(client: LoggedInClient): + """ + Test accepting a friend request returns friendship dict. + """ + # Create another user and send friend request + other_email = "accept@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="accept", + learned_language="de") + + # Get users + sender_user = User.find(client.email) + other_user = User.find(other_email) + + # Send friend request + fr_response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert fr_response["friend_request_status"] == "pending" + + # User other client to accept friend request + accept_fr_response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + assert accept_fr_response["friend_request_status"] == "accepted" + + +def test_reject_friend_request_success(client: LoggedInClient): + """ + Test rejecting a friend request returns success message. + """ + other_email = "reject@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="reject", + learned_language="de") + + # Find users and ids + other_user = User.find(other_email) + sender_user = User.find(client.email) + + # Act: Send friend request and reject it + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + response = other_client.post("/reject_friend_request", json={"sender_id": sender_user.id}) + + # Assert + assert response.get("success") is True + +def test_delete_friend_request_success(client: LoggedInClient): + """ + Test deleting a friend request returns 'True' or similar. + """ + # Create another user and send friend request + other_email = "delete@user.com" + user_data = dict(password="test", username="delete", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + + other_user = User.find(other_email) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + response = client.post("/delete_friend_request", json={"receiver_id": other_user.id}) + assert "True" in str(response) or response is True + + +def test_delete_friend_request_invalid_receiver(client: LoggedInClient): + """ + Test deleting a friend request with invalid receiver returns error. + """ + response = client.post("/delete_friend_request", json={"receiver_id": 999999}) + assert response.get("success") is False + +def test_send_friend_request_success(client: LoggedInClient): + """ + Test sending a friend request to another user returns expected dict. + """ + # Create another user + other_email = "other@user.com" + user_data = dict(password="test", username="other", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + # Send friend request + response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert isinstance(response, dict) + assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id + + +def test_send_friend_request_to_self(client: LoggedInClient): + """ + Test sending a friend request to self returns error. + """ + from zeeguu.core.model import User + user = User.find(client.email) + response = client.post("/send_friend_request", json={"receiver_id": user.id}) + assert "cannot send friend request to yourself" in str(response) + +def test_get_friend_requests(client: LoggedInClient): + """ + Test the /get_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_friend_requests") + assert isinstance(response, list) + + +def test_get_pending_friend_requests(client: LoggedInClient): + """ + Test the /get_pending_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_pending_friend_requests") + assert isinstance(response, list) + +def test_unfriend_success(client: LoggedInClient): + other_email = "unfriend@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="unfriend", + learned_language="de") + sender_user = User.find(client.email) + other_user = User.find(other_email) + + # Send and accept friend request + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + # Act: Unfirend + response = client.post("/unfriend", json={"receiver_id": other_user.id}) + + # Assert + assert response.get("success") is True + +def test_search_users(client: LoggedInClient): + """ + Test search_users returns a list. + """ + response = client.get("/search_users/test") + assert isinstance(response, list) + +def test_get_friends(client: LoggedInClient): + """ + Test the /get_friends endpoint returns a list (empty or not). + """ + response = client.get("/get_friends") + assert isinstance(response, list) + + +def test_get_friends_for_friend_user_id(client: LoggedInClient): + """ + Test /get_friends/ excludes the requester from the friend's friends list. + """ + other_email = "friends-list@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="friends-list", + learned_language="de", + ) + third_email = "friends-list-third@user.com" + third_client = LoggedInClient( + client.client, + email=third_email, + password="test", + username="friends-list-third", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + third_user = User.find(third_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + third_client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": third_user.id}) + + response = client.get(f"/get_friends/{other_user.id}") + + assert isinstance(response, list) + friend_ids = [entry["id"] for entry in response] + assert sender_user.id not in friend_ids + assert third_user.id in friend_ids + + +def test_get_friends_for_non_friend_user_denied(client: LoggedInClient): + """ + Test /get_friends/ denies access when users are not friends. + """ + stranger_email = "friends-private@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="friends-private", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/get_friends/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("message") == "You can only view friends for yourself or your friends." + + +def test_get_user_details_returns_current_user_data(client: LoggedInClient): + """ + Test /get_user_details returns the logged-in user's details and feature flags. + """ + user = User.find(client.email) + + response = client.get("/get_user_details") + + assert isinstance(response, dict) + assert response["email"] == client.email + assert response["name"] == user.name + assert "learned_language" in response + assert "native_language" in response + assert "features" in response + + +def test_get_friend_details_returns_data_for_friend(client: LoggedInClient): + """ + Test /get_user_details/ returns details when users are friends. + """ + other_email = "friend-details@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="friend-details", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + response = client.get(f"/get_user_details/{other_user.id}") + + assert isinstance(response, dict) + assert response["email"] == other_email + assert response["name"] == other_user.name + assert "learned_language" in response + assert "native_language" in response + assert "friends_since" in response + assert "mutual_streak" in response + assert isinstance(response["mutual_streak"], int) + assert response["friendship"]["friend_request_status"] == "accepted" + + +def test_get_friend_details_pending_request_shows_pending_status(client: LoggedInClient): + """ + Test /get_user_details/ shows friendship.friend_request_status='pending' + when a friend request has been sent but not yet accepted. + """ + pending_email = "pending-details@user.com" + LoggedInClient( + client.client, + email=pending_email, + password="test", + username="pending-details", + learned_language="de", + ) + pending_user = User.find(pending_email) + + client.post("/send_friend_request", json={"receiver_id": pending_user.id}) + + response = client.get(f"/get_user_details/{pending_user.id}") + + assert isinstance(response, dict) + assert response["friendship"]["friend_request_status"] == "pending" + + +def test_get_friend_details_no_relationship_returns_none_friendship(client: LoggedInClient): + """ + Test /get_user_details/ returns friendship=None when there is + no friendship or friend request between the users. + """ + stranger_email = "no-relation@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="no-relation", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/get_user_details/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("friendship") is None + diff --git a/zeeguu/api/utils/__init__.py b/zeeguu/api/utils/__init__.py index c2a7a07d..45891506 100644 --- a/zeeguu/api/utils/__init__.py +++ b/zeeguu/api/utils/__init__.py @@ -1,3 +1,3 @@ -from .route_wrappers import cross_domain, requires_session +from .route_wrappers import cross_domain, requires_session, update_user_streak from .json_result import json_result from .parse_json_boolean import parse_json_boolean diff --git a/zeeguu/api/utils/route_wrappers.py b/zeeguu/api/utils/route_wrappers.py index bed68fe8..c4543b6f 100644 --- a/zeeguu/api/utils/route_wrappers.py +++ b/zeeguu/api/utils/route_wrappers.py @@ -89,13 +89,27 @@ def wrapped_view(*args, **kwargs): if user: user.update_last_seen_if_needed(db.session) - # Update per-language streak for the user's current learned language + # Reset streak for all learned languages if user hasn't practiced in 2+ days + # (streak is only incremented in actual practice endpoints). if user.learned_language: - user_language = UserLanguage.find_or_create( - db.session, user, user.learned_language - ) - user_language.update_streak_if_needed(db.session) - # Commit immediately since this is a simple timestamp update + user_languages = UserLanguage.all_user_languages_for_user(user) + daily_streak_badge_progress = 0 + any_streak_changed = False + for user_language in user_languages: + original_streak = user_language.daily_streak + user_language.reset_streak_if_broken(db.session) + updated_streak = user_language.daily_streak + if updated_streak > daily_streak_badge_progress: + daily_streak_badge_progress = updated_streak + if original_streak != updated_streak: + any_streak_changed = True + + # If any learned language's daily streak has been reset, we need to update the STREAK_COUNT badge's + # progress to the new highest streak among all languages. + if any_streak_changed: + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress + update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) + db.session.commit() # Check email verification (unless endpoint is marked as allowing unverified) @@ -193,3 +207,24 @@ def wrapped_view(*args, **kwargs): return wrapped_view +def update_user_streak(): + """ + Call this in practice endpoints to update the user's daily streak. + Should be called when user performs actual practice activities: + - Completing exercises + - Reading articles (creating bookmarks/translations) + - Listening to audio lessons + """ + from zeeguu.core.model import User + from zeeguu.core.model.user_language import UserLanguage + from zeeguu.core.model.db import db + + user = User.find_by_id(flask.g.user_id) + if user and user.learned_language: + user_language = UserLanguage.find_or_create( + db.session, user, user.learned_language + ) + user_language.update_streak_if_needed(user, db.session) + db.session.commit() + + diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 6b8a07d8..ccc2567f 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -59,7 +59,6 @@ def create_account( learned_language = Language.find_or_create(learned_language_code) native_language = Language.find_or_create(native_language_code) - new_user = User( email, username, diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index 825ce8ea..de88a96d 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -785,6 +785,9 @@ def update_lesson_state_for_user(self, user, lesson_id, state_data): # Send email notification for lesson completion self._send_lesson_completion_notification(lesson, user) + from zeeguu.core.badges.badge_progress import BadgeCode, increment_badge_progress + increment_badge_progress(db.session, BadgeCode.COMPLETED_AUDIO_LESSONS, user.id) + else: return { "error": f"Invalid action: {action}. Must be 'play', 'pause', 'resume', or 'complete'", diff --git a/zeeguu/core/badges/__init__.py b/zeeguu/core/badges/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py new file mode 100644 index 00000000..578d9e81 --- /dev/null +++ b/zeeguu/core/badges/badge_progress.py @@ -0,0 +1,89 @@ +from zeeguu.core.model.user_badge_progress import UserBadgeProgress +from zeeguu.core.model.badge import BadgeCode, Badge +from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.core.model.user_badge_level import UserBadgeLevel +from zeeguu.logging import log + + +def _award_badge_levels(db_session, badge_id: int, user_id: int, current_value: int) -> list[UserBadgeLevel]: + """ + Create UserBadgeLevel entries for all newly achieved levels. + Returns only newly created levels. + """ + badge_level_ids = db_session.scalars( + db_session.query(BadgeLevel.id) + .filter( + BadgeLevel.badge_id == badge_id, + BadgeLevel.target_value <= current_value + ) + .order_by(BadgeLevel.level.asc()) + ).all() + + if not badge_level_ids: + return [] + + existing_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) + owned_ids = {lvl.badge_level_id for lvl in existing_levels} + + missing_ids = [lvl_id for lvl_id in badge_level_ids if lvl_id not in owned_ids] + + created_badges = [ + UserBadgeLevel(user_id=user_id, badge_level_id=level_id) + for level_id in missing_ids + ] + + db_session.add_all(created_badges) + + return created_badges + + +def increment_badge_progress(db_session, badge_code: BadgeCode, user_id: int, increment_value: int = 1) \ + -> list[UserBadgeLevel]: + """ + Increment a user's badge progress and award newly achieved levels. + Returns newly created UserBadgeLevel records. + """ + badge = Badge.find(badge_code) + if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") + return [] + + progress = UserBadgeProgress.create_or_increment( + db_session, + user_id, + badge.id, + increment_value + ) + + return _award_badge_levels( + db_session, + badge.id, + user_id, + progress.current_value + ) + + +def update_badge_progress(db_session, badge_code: BadgeCode, user_id: int, current_value: int) \ + -> list[UserBadgeLevel]: + """ + Overwrite a user's badge progress and award newly achieved levels. + Returns newly created UserBadgeLevel records. + """ + badge = Badge.find(badge_code) + if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") + return [] + + progress = UserBadgeProgress.create_or_update( + db_session, + user_id, + badge.id, + current_value + ) + + return _award_badge_levels( + db_session, + badge.id, + user_id, + progress.current_value + ) diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py new file mode 100644 index 00000000..59278cee --- /dev/null +++ b/zeeguu/core/model/badge.py @@ -0,0 +1,47 @@ +import enum + +from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.core.model.db import db + + +class BadgeCode(enum.Enum): + """Enum representing all available badge codes in the system.""" + TRANSLATED_WORDS = 'TRANSLATED_WORDS' + CORRECT_EXERCISES = 'CORRECT_EXERCISES' + COMPLETED_AUDIO_LESSONS = 'COMPLETED_AUDIO_LESSONS' + STREAK_COUNT = 'STREAK_COUNT' + LEARNED_WORDS = 'LEARNED_WORDS' + READ_ARTICLES = 'READ_ARTICLES' + NUMBER_OF_FRIENDS = 'NUMBER_OF_FRIENDS' + + +class Badge(db.Model): + """ + Represents a badge that can be earned by users. Each badge can have + multiple levels defined in BadgeLevel. + """ + __tablename__ = "badge" + + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.Enum(BadgeCode), nullable=False, unique=True) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + + __table_args__ = ( + db.UniqueConstraint("code"), + ) + + badge_levels = db.relationship(BadgeLevel, back_populates="badge", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + @classmethod + def find(cls, code: BadgeCode) -> "Badge": + """ + Find a badge by its BadgeCode enum value. + + Returns: + Badge object if found, else None. + """ + return cls.query.filter_by(code=code).first() diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py new file mode 100644 index 00000000..7e1b516d --- /dev/null +++ b/zeeguu/core/model/badge_level.py @@ -0,0 +1,61 @@ +from zeeguu.core.model.db import db + + +class BadgeLevel(db.Model): + """ + Represents a level of a badge. Each badge can have multiple levels, each + with a target value and optional icon. Levels are unique per badge. + """ + __tablename__ = "badge_level" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + level = db.Column(db.Integer, nullable=False) + target_value = db.Column(db.Integer, nullable=False) + icon_name = db.Column(db.String(255)) + + __table_args__ = ( + db.UniqueConstraint("badge_id", "level"), + ) + + badge = db.relationship("Badge", back_populates="badge_levels") + user_badge_levels = db.relationship( + "UserBadgeLevel", + back_populates="badge_level", + cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + @classmethod + def find_all_achievable(cls, badge_id: int, current_value: int) -> list["BadgeLevel"]: + """ + Find all badge levels for a specific badge that the user can achieve given their current value. + + Args: + badge_id: The ID of the badge. + current_value: The user's current value for the badge metric. + + Returns: + List of BadgeLevel objects that are achievable. + """ + return cls.query.filter( + cls.badge_id == badge_id, + cls.target_value <= current_value + ).all() + + @classmethod + def find(cls, badge_id: int, level: int) -> "BadgeLevel | None": + """ + Find a specific badge level by badge ID and level number. + + Args: + badge_id: The ID of the badge. + level: The level number. + + Returns: + BadgeLevel object if found, else None. + """ + return cls.query.filter_by(badge_id=badge_id, level=level).first() diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index 85fb6f51..e3cd3a15 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, Integer, Text, JSON, ForeignKey, TIMESTAMP +from sqlalchemy import Column, Integer, JSON, ForeignKey, TIMESTAMP from sqlalchemy.orm import relationship from zeeguu.core.model.db import db @@ -157,3 +157,4 @@ def find_latest_for_user(cls, user, include_completed=False): if not include_completed: query = query.filter(cls.completed_at.is_(None)) return query.order_by(cls.recommended_at.desc()).first() + diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py new file mode 100644 index 00000000..d0d3edc4 --- /dev/null +++ b/zeeguu/core/model/friend.py @@ -0,0 +1,306 @@ +from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_ +from sqlalchemy.orm import relationship, object_session +from zeeguu.core.model.db import db +from zeeguu.core.model.user import User # assuming you have a User model +from datetime import datetime, timedelta + +class Friend(db.Model): + __tablename__ = "friends" + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) + created_at = Column(DateTime, default=func.now()) + friend_streak = Column(Integer, default=0) # Tracks streak with this friend + friend_streak_last_updated = Column(DateTime, nullable=True) + + + def update_friend_streak(self, session=None, commit=True): + """ + Update friend_streak based on both users' most recent practice in any language. + Uses the latest last_practiced date across all UserLanguage records for each user. + """ + from zeeguu.core.model.user_language import UserLanguage + + session = session or object_session(self) or db.session + + # Get all UserLanguage records for each user + user_langs = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).all() + friend_langs = UserLanguage.query.filter(UserLanguage.user_id == self.friend_id).all() + + # Find the most recent last_practiced date for each user + user_date = None + friend_date = None + if user_langs: + user_date = max((ul.last_practiced for ul in user_langs if ul.last_practiced), default=None) + if friend_langs: + friend_date = max((ul.last_practiced for ul in friend_langs if ul.last_practiced), default=None) + + user_date = user_date.date() if user_date else None + friend_date = friend_date.date() if friend_date else None + today = datetime.now().date() + yesterday = today - timedelta(days=1) + last_updated_date = ( + self.friend_streak_last_updated.date() + if self.friend_streak_last_updated + else None + ) + + # If both practiced today, update at most once per day. + if user_date == today and friend_date == today: + if last_updated_date != today: + if last_updated_date == yesterday and (self.friend_streak or 0) > 0: + self.friend_streak = (self.friend_streak or 0) + 1 + else: + self.friend_streak = 1 + self.friend_streak_last_updated = datetime.now() + # Do not reset if one side has never practiced yet. + elif user_date is None or friend_date is None: + pass + # Reset only when at least one side has not practiced since before yesterday. + elif user_date < yesterday or friend_date < yesterday: + self.friend_streak = 0 + self.friend_streak_last_updated = datetime.now() + + if session: + session.add(self) + if commit: + session.commit() + + # Explicit relationships with primaryjoin + user = relationship( + User, + foreign_keys=[user_id], + primaryjoin="Friend.user_id == User.id" + ) + friend = relationship( + User, + foreign_keys=[friend_id], + primaryjoin="Friend.friend_id == User.id" + ) + + @staticmethod + def get_friends(user_id): + """Return a list of User objects that are friends with the given user_id.""" + # query where user is either the user_id or the friend_id + friends = ( + db.session.query(User) + .join(Friend, or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + .filter( + (Friend.user_id == user_id) & (User.id == Friend.friend_id) + | (Friend.friend_id == user_id) & (User.id == Friend.user_id) + ) + .all() + ) + return friends + + @staticmethod + def get_friends_with_friendship(user_id: int, exclude_user_id: int = None): + """Return combined friend user + friendship data for the given user. + + exclude_user_id: if provided, that user is omitted from the results. + """ + friendships : list[Friend] = Friend.query.filter( + (Friend.user_id == user_id) | (Friend.friend_id == user_id) + ).all() + + if not friendships: + return [] + + other_user_ids = [ + friendship.friend_id if friendship.user_id == user_id else friendship.user_id + for friendship in friendships + ] + + users = User.query.filter(User.id.in_(other_user_ids)).all() + users_by_id = {user.id: user for user in users} + + result = [] + for friendship in friendships: + + if friendship.user_id == user_id: + other_user_id = friendship.friend_id + else: + other_user_id = friendship.user_id + + # Exclude if matches exclude_user_id (usually exclude_user_id is the current user, to avoid returning self as a friend) + if exclude_user_id is not None and other_user_id == exclude_user_id: + continue + + friend_user = users_by_id.get(other_user_id) + if not friend_user: + continue + result.append({"user": friend_user, "friendship": friendship}) + + return result + + @classmethod + def are_friends(cls, user1_id: int, user2_id: int) -> bool: + """Return True if two users are friends (in either direction).""" + if user1_id is None or user2_id is None: + return False + + if user1_id == user2_id: + return True + + friendship = cls.query.filter( + ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | + ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) + ).first() + return friendship is not None + + @classmethod + def remove_friendship(cls, user1_id: int, user2_id: int)->bool: + # Look for friendship in either direction + friendship = cls.query.filter( + ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | + ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) + ).first() + + if friendship: + db.session.delete(friendship) + db.session.commit() + return True + + return False + + @classmethod + def find_friend_details(cls, user_id: int, friend_user_id: int): + """ + Return details_as_dictionary for friend_user_id. + Always includes a 'friendship' object with friend_request_status + ('accepted', 'pending', 'rejected', or None if no relationship). + When friends, also includes friends_since and mutual_streak. + Returns None if the target user is not found. + """ + from zeeguu.core.model.user import User + from zeeguu.core.model.friend_request import FriendRequest + + friend = User.find_by_id(friend_user_id) + if not friend: + return None + + friendship = cls.query.filter( + ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | + ((cls.user_id == friend_user_id) & (cls.friend_id == user_id)) + ).first() + + friend_request = FriendRequest.query.filter( + ((FriendRequest.sender_id == user_id) & (FriendRequest.receiver_id == friend_user_id)) | + ((FriendRequest.sender_id == friend_user_id) & (FriendRequest.receiver_id == user_id)) + ).order_by(FriendRequest.created_at.desc()).first() + + details = friend.details_as_dictionary() + details["friendship"] = cls._get_friendship_or_friendrequest(friendship, friend_request) + + if friendship: + details["friends_since"] = friendship.created_at.isoformat() if friendship.created_at else None + details["mutual_streak"] = friendship.friend_streak or 0 + + return details + + + @staticmethod + def search_users(current_user_id: int, term: str, limit: int = 20): + """ + Search users by username (partial match) or exact email. + For each user, return: + - user info + - friend request status (if any) + - friendship status (if any) + """ + from zeeguu.core.model.friend_request import FriendRequest + + # Build base query + filters = [] + if term: + filters.append(func.lower(User.username).ilike(f"%{term}%")) # ilike for case-insensitive partial match + filters.append(func.lower(User.email) == term) # exact match for email + filters.append(func.lower(User.name) == term) # exact match for name + + if not filters: + return [] # nothing to search + + query = User.query + query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) + + results = [] + for user in query.all(): + # Friendship status + friendship = Friend.query.filter( + ((Friend.user_id == current_user_id) & (Friend.friend_id == user.id)) | + ((Friend.user_id == user.id) & (Friend.friend_id == current_user_id)) + ).first() + + # Friend request status + friend_request = FriendRequest.query.filter( + ((FriendRequest.sender_id == current_user_id) & (FriendRequest.receiver_id == user.id)) | + ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) + ).order_by(FriendRequest.created_at.desc()).first() + + + friendship_or_friend_request = Friend._get_friendship_or_friendrequest( + friendship, + friend_request) + + results.append({ + "user": { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + "friendship": friendship_or_friend_request, + }, + }) + return results + @staticmethod + def _get_friendship_or_friendrequest(friendship, friend_request): + + if friendship: + return { + "id": friendship.id, + "friend_streak": friendship.friend_streak, + "friend_streak_last_updated": ( + friendship.friend_streak_last_updated.isoformat() + if friendship.friend_streak_last_updated + else None + ), + "friend_request_status": "accepted", + "created_at": friendship.created_at.isoformat() if friendship.created_at else None, + } + elif friend_request: + return { + "id": friend_request.id, + "sender_id": friend_request.sender_id, # TODO: Are these nessesary + "receiver_id": friend_request.receiver_id, # TODO: are these nesessary + "friend_streak": 0, + "friend_streak_last_updated": None, + "friend_request_status": friend_request.status, + "created_at": ( + friend_request.created_at.isoformat() + if friend_request.created_at + else None + ), + } + + def add_friendship(user_id: int, friend_id: int): + """ + Adds a friendship between two users using SQLAlchemy ORM. + Stores both directions for easy querying. + """ + # Check if friendship already exists + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | + ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) + ).first() + + if existing: + return existing # friendship already exists + + # Add friendship + friendship = Friend(user_id=user_id, friend_id=friend_id) + db.session.add(friendship) + db.session.commit() + db.session.refresh(friendship) + return friendship \ No newline at end of file diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py new file mode 100644 index 00000000..8089da40 --- /dev/null +++ b/zeeguu/core/model/friend_request.py @@ -0,0 +1,173 @@ +from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func +from sqlalchemy.orm import relationship +from zeeguu.core.model.db import db +from zeeguu.core.model.user import User # assuming you have a User model +from zeeguu.core.model.friend import Friend # assuming you have a User model +from sqlalchemy.exc import NoResultFound + +class FriendRequest(db.Model): + __tablename__ = "friend_requests" + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True, autoincrement=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("user.id"), nullable=False) + status = Column( + Enum("pending", "accepted", "rejected", name="friend_request_status"), + default="pending", + ) + created_at = Column(DateTime, default=func.now()) + responded_at = Column(DateTime, nullable=True) + + # relationships + # Relationships — explicit foreign_keys and primaryjoin + sender = relationship( + User, + foreign_keys=[sender_id], + primaryjoin="FriendRequest.sender_id == User.id" + ) + receiver = relationship( + User, + foreign_keys=[receiver_id], + primaryjoin="FriendRequest.receiver_id == User.id" + ) + + def __init__( + self, + sender_id, + receiver_id, + status="pending" + ): + self.sender_id = sender_id + self.receiver_id = receiver_id + self.status = status + + + def __repr__(self): + return "id: "+ str(self.id) + "sender: "+ str(self.sender_id) + "reciever: " + str( self.receiver_id) + + @classmethod + def send_friend_request(cls, sender_id: int, receiver_id: int): + """ + Send a friend request from sender to receiver. + + Args: + sender_id (int): ID of the user sending the request + receiver_id (int): ID of the user receiving the request + + Returns: + FriendRequest: The created friend request object + """ + + # Prevent sending request to self + if sender_id == receiver_id: + raise ValueError("Cannot send a friend request to yourself.") + + # Check if users exist + sender = db.session.query(User).filter_by(id=sender_id).first() + receiver = db.session.query(User).filter_by(id=receiver_id).first() + if not sender or not receiver: + raise ValueError("Sender or receiver does not exist.") + + # Check for existing friend request + existing_request = db.session.query(cls).filter( + ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | + ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) + ).first() + + if existing_request: + raise ValueError("A friend request already exists between these users.") + + # Create new friend request + new_request = FriendRequest( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" ## TODO: This should be an enum/constant + ) + + db.session.add(new_request) + db.session.commit() + db.session.refresh(new_request) # To get the ID and timestamps + + return new_request + + @classmethod + def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): + """ + Get friend requests received by a user. + + Args: + session (Session): SQLAlchemy session + user_id (int): ID of the user + status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" + + Returns: + List[FriendRequest]: List of friend request objects + """ + requests = ( + db.session.query(cls) + .filter(FriendRequest.receiver_id == user_id) + .filter(FriendRequest.status == status) + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests + + @classmethod + def get_pending_friend_requests_for_user(cls, user_id: int): + """ + Get pending friend requests received by a user. + + Args: + cls (FriendRequest): The FriendRequest class + user_id (int): ID of the user + + Returns: + List[FriendRequest]: List of pending friend request objects + """ + requests = ( + db.session.query(cls) + .filter(FriendRequest.sender_id == user_id) + .filter(FriendRequest.status == "pending") + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests + + @classmethod + def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: + """Delete a friend request between sender and receiver.""" + try: + fr = db.session.query(cls).filter_by( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" # usually only pending requests can be deleted + ).one() + db.session.delete(fr) + db.session.commit() + return True + except NoResultFound: + return False + + @classmethod + def accept_friend_request(cls, sender_id: int, receiver_id: int): + try: + # Find the pending request + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + if not is_deleted: + raise None # If the request was not found or could not be deleted, we cannot accept it + + # Create the friendship record in the database + friendship = Friend.add_friendship(sender_id, receiver_id) + return friendship + except NoResultFound: + return None + + @classmethod + def reject_friend_request(cls, sender_id: int, receiver_id: int): + try: + # We just delete the friend request from the database + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + return is_deleted + except NoResultFound: + return False \ No newline at end of file diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264..51a26500 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -44,6 +44,7 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) name = db.Column(db.String(255)) + username = db.Column(db.String(50), unique=True, index=True) invitation_code = db.Column(db.String(255)) password = db.Column(db.String(255)) password_salt = db.Column(db.String(255)) @@ -67,6 +68,7 @@ def __init__( email, name, password, + username=None, learned_language=None, native_language=None, invitation_code=None, @@ -75,8 +77,9 @@ def __init__( ): from datetime import datetime - self.email = email - self.name = name + self.email = email + self.name = name # The name of the user + self.username = username or self.generate_username() # Username is custom name to display in UI self.update_password(password) self.learned_language = learned_language or Language.default_learned() self.native_language = native_language or Language.default_native_language() @@ -85,6 +88,36 @@ def __init__( self.created_at = datetime.now() self.creation_platform = creation_platform + ADJECTIVES = [ + "brave", "clever", "curious", "silent", "rapid", + "happy", "bright", "playful", "bold", "calm", + "gentle", "keen", "witty", "daring", "serene", + "lively", "mighty", "patient", "vivid", "wise" + ] + + NOUNS = [ + "otter", "falcon", "wolf", "fox", "owl", "panther", + "lion", "tiger", "bear", "eagle", "rabbit", "deer", + "leopard", "cheetah", "badger", "beaver", "lynx", "moose" + ] + + MAX_NUMBER_USERNAME = 9999 + + @classmethod + def generate_username(cls): + """ + :summary: + + Generate a random username in the format 'adjective_noun1234' + Can currently generate 20 x 18 x 9999 = 3,598,200 unique usernames + + :return: A string username + """ + adjective = random.choice(cls.ADJECTIVES) + noun = random.choice(cls.NOUNS) + number = random.randint(1, cls.MAX_NUMBER_USERNAME) + return f"{adjective}_{noun}{number}" + @classmethod def create_anonymous( cls, @@ -160,6 +193,7 @@ def details_as_dictionary(self): from datetime import datetime from zeeguu.core.model import UserLanguage from zeeguu.core.model.bookmark import Bookmark + from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model.user_word import UserWord # Only require email verification for users created after this date @@ -183,9 +217,22 @@ def details_as_dictionary(self): and not self.email_verified ) + # Get the corresponding avatar details + user_avatar = UserAvatar.find(self.id) + user_avatar_dict = ( + dict( + image_name=user_avatar.image_name, + character_color=user_avatar.character_color, + background_color=user_avatar.background_color, + ) + if user_avatar + else None + ) + result = dict( email=self.email, name=self.name, + username=self.username, learned_language=self.learned_language.code, native_language=self.native_language.code, is_teacher=self.isTeacher(), @@ -196,6 +243,8 @@ def details_as_dictionary(self): requires_email_verification=requires_email_verification, bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), + created_at=self.created_at.isoformat() if self.created_at else None, + user_avatar=user_avatar_dict, ) for each in UserLanguage.query.filter_by(user=self): @@ -401,19 +450,12 @@ def active_during_recent(self, days: int = 30): def update_last_seen_if_needed(self, session=None): """ Update last_seen timestamp, but only once per day to minimize database writes. - Also maintains the daily_streak counter. + Note: daily_streak is now tracked per-language in UserLanguage model. """ now = datetime.datetime.now() # Only update if last_seen is None or it's a different day if not self.last_seen or self.last_seen.date() < now.date(): - if not self.last_seen: - self.daily_streak = 1 - elif self.last_seen.date() == now.date() - datetime.timedelta(days=1): - self.daily_streak = (self.daily_streak or 0) + 1 - else: - self.daily_streak = 1 - self.last_seen = now if session: session.add(self) diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py new file mode 100644 index 00000000..1982a52c --- /dev/null +++ b/zeeguu/core/model/user_avatar.py @@ -0,0 +1,55 @@ +from typing import Optional + +from zeeguu.core.model.db import db + + +class UserAvatar(db.Model): + """ + + """ + __tablename__ = "user_avatar" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + image_name = db.Column(db.String(100)) + character_color = db.Column(db.String(7)) + background_color = db.Column(db.String(7)) + + user = db.relationship("User") + + def __init__( + self, + user_id, + image_name=None, + character_color=None, + background_color=None + ): + self.user_id = user_id + self.image_name = image_name + self.character_color = character_color + self.background_color = background_color + + def __repr__(self): + return f"" + + @classmethod + def find(cls, user_id: int) -> Optional["UserAvatar"]: + """ + Return the corresponding avatar for the given user. + """ + return cls.query.filter_by(user_id=user_id).one_or_none() + + @classmethod + def update_or_create(cls, user_id, image_name, character_color, background_color): + """ + Update an existing avatar or create a new one for the specified user. + Does not commit. + """ + user_avatar = cls.find(user_id) + if user_avatar: + user_avatar.image_name = image_name + user_avatar.character_color = character_color + user_avatar.background_color = background_color + else: + user_avatar = UserAvatar(user_id, image_name, character_color, background_color) + return user_avatar diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py new file mode 100644 index 00000000..228f3b0a --- /dev/null +++ b/zeeguu/core/model/user_badge_level.py @@ -0,0 +1,84 @@ +from datetime import datetime + +from zeeguu.core.model.db import db + + +class UserBadgeLevel(db.Model): + """ + Represents the association between a user and a badge level they have achieved. + Tracks when the badge level was achieved and whether it has been shown to the user. + """ + __tablename__ = "user_badge_level" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) + achieved_at = db.Column(db.DateTime, default=datetime.now) + is_shown = db.Column(db.Boolean, default=False) + + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_level_id"), + ) + + badge_level = db.relationship("BadgeLevel", back_populates="user_badge_levels") + user = db.relationship("User") + + def __init__( + self, + user_id, + badge_level_id, + achieved_at=None, + is_shown=False + ): + self.user_id = user_id + self.badge_level_id = badge_level_id + self.is_shown = is_shown + self.achieved_at = achieved_at or datetime.now() + + def __repr__(self): + return f"" + + @classmethod + def find_all(cls, user_id: int) -> list["UserBadgeLevel"]: + """Return all badge levels achieved by a user.""" + return cls.query.filter_by(user_id=user_id).all() + + @classmethod + def count_user_not_shown(cls, user_id: int) -> int: + """Return the count of badge levels that the user has not seen yet.""" + return cls.query.filter_by(user_id=user_id, is_shown=False).count() + + @classmethod + def find(cls, user_id: int, badge_level_ids: list[int]) -> list["UserBadgeLevel"]: + """Return user badge levels for the specified badge_level_ids.""" + if not badge_level_ids: + return [] + return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() + + @classmethod + def update_not_shown_for_user(cls, session, user_id: int): + """ + Mark all badge levels for a user as shown. + Does not commit. + """ + unseen_levels = cls.query.filter_by(user_id=user_id, is_shown=False).all() + for level in unseen_levels: + level.is_shown = True + session.add(level) + + @classmethod + def create( + cls, + session, + user_id: int, + badge_level_id: int, + achieved_at: datetime = None, + is_shown: bool = False, + ) -> "UserBadgeLevel": + """ + Create a new UserBadgeLevel record for a user and badge level. + Does not commit. + """ + new = cls(user_id, badge_level_id, achieved_at, is_shown) + session.add(new) + return new diff --git a/zeeguu/core/model/user_badge_progress.py b/zeeguu/core/model/user_badge_progress.py new file mode 100644 index 00000000..c73d2494 --- /dev/null +++ b/zeeguu/core/model/user_badge_progress.py @@ -0,0 +1,100 @@ +from zeeguu.core.model.db import db + + +class UserBadgeProgress(db.Model): + """ + Tracks a user's progress toward a specific badge. + Stores the current metric value used to determine badge level eligibility. + """ + __tablename__ = "user_badge_progress" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + current_value = db.Column(db.Integer, nullable=False, default=0) + + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_id"), + ) + + badge = db.relationship("Badge") + user = db.relationship("User") + + def __init__( + self, + user_id, + badge_id, + current_value=0 + ): + self.user_id = user_id + self.badge_id = badge_id + self.current_value = current_value + + def __repr__(self): + return f"" + + @classmethod + def find_all(cls, user_id: int) -> list["UserBadgeProgress"]: + """Return all badge progress records for a user.""" + return cls.query.filter_by(user_id=user_id).all() + + @classmethod + def find(cls, user_id: int, badge_ids: list[int]) -> list["UserBadgeProgress"]: + """Return progress records for the given badge IDs.""" + if not badge_ids: + return [] + return cls.query.filter(cls.user_id == user_id, cls.badge_id.in_(badge_ids)).all() + + @classmethod + def _get_or_create( + cls, + session, + user_id: int, + badge_id: int, + ) -> "UserBadgeProgress": + """ + Internal helper to fetch existing progress or create a new one (value=0). + Does not commit. + """ + record = cls.query.filter_by( + user_id=user_id, + badge_id=badge_id + ).one_or_none() + + if not record: + record = cls(user_id=user_id, badge_id=badge_id, current_value=0) + session.add(record) + + return record + + @classmethod + def create_or_increment( + cls, + session, + user_id: int, + badge_id: int, + increment: int + ) -> "UserBadgeProgress": + """ + Increment current_value by the given amount. + """ + record = cls._get_or_create(session, user_id, badge_id) + record.current_value += increment + session.add(record) + return record + + @classmethod + def create_or_update( + cls, + session, + user_id: int, + badge_id: int, + value: int + ) -> "UserBadgeProgress": + """ + Overwrite current_value with a new value. + """ + record = cls._get_or_create(session, user_id, badge_id) + record.current_value = value + session.add(record) + return record diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 128e444e..98c3cf51 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -48,6 +48,8 @@ class UserLanguage(db.Model): last_practiced = Column(DateTime, nullable=True) daily_streak = Column(Integer, default=0) + max_streak = Column(Integer, default=0) + max_streak_date = Column(DateTime, nullable=True) def __init__( self, @@ -108,7 +110,7 @@ def with_language_id(cls, i, user): return cls.query.filter(cls.user == user).filter(cls.language_id == i).one() @classmethod - def all_for_user(cls, user): + def all_languages_for_user(cls, user): user_main_learned_language = user.learned_language user_languages = [ language_id.language @@ -120,12 +122,18 @@ def all_for_user(cls, user): return user_languages - def update_streak_if_needed(self, session=None): + @classmethod + def all_user_languages_for_user(cls, user): + return cls.query.filter(cls.user == user).all() + + def update_streak_if_needed(self, user, session=None): """ Update last_practiced timestamp and daily_streak counter for this language. + Call this when user performs actual practice (exercises, reading, etc.). Only updates once per day to minimize database writes. """ now = datetime.datetime.now() + active_session = session or db.session if not self.last_practiced or self.last_practiced.date() < now.date(): if not self.last_practiced: @@ -133,8 +141,52 @@ def update_streak_if_needed(self, session=None): elif self.last_practiced.date() == now.date() - datetime.timedelta(days=1): self.daily_streak = (self.daily_streak or 0) + 1 else: + # Gap in practice - save max before resetting + self._update_max_streak_if_needed() self.daily_streak = 1 self.last_practiced = now + self._update_max_streak_if_needed() + active_session.add(self) + + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress + daily_streak_badge_progress = max( + [user_language.daily_streak for user_language in self.all_user_languages_for_user(user)] + ) + update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) + + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak(session=active_session, commit=False) + + active_session.commit() + + def reset_streak_if_broken(self, session=None): + """ + Reset streak to 0 if user hasn't practiced since yesterday. + Call this on login/session validation to ensure streak reflects reality. + Does NOT update last_practiced or increment streak. + """ + if not self.last_practiced: + return + + now = datetime.datetime.now() + yesterday = now.date() - datetime.timedelta(days=1) + + # If last practice was before yesterday, streak is broken + if self.last_practiced.date() < yesterday: + self._update_max_streak_if_needed() + self.daily_streak = 0 if session: session.add(self) + + def _update_max_streak_if_needed(self): + """Update max_streak if current streak exceeds it.""" + if self.daily_streak > (self.max_streak or 0): + self.max_streak = self.daily_streak + self.max_streak_date = self.last_practiced diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index f9da142e..bf40e644 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -392,6 +392,11 @@ def report_exercise_outcome( ) db_session.add(exercise) + if source.source != "DAILY_AUDIO_LESSON" and outcome.correct: + from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode + increment_badge_progress(db_session, BadgeCode.CORRECT_EXERCISES, self.user.id) + + scheduler = self.get_scheduler() scheduler.update(db_session, self, exercise_outcome, time) diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py new file mode 100644 index 00000000..908b9c9f --- /dev/null +++ b/zeeguu/core/test/test_friends.py @@ -0,0 +1,158 @@ +from datetime import datetime, timedelta + +import zeeguu.core +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.user_language import UserLanguage +from zeeguu.core.test.model_test_mixin import ModelTestMixIn +from zeeguu.core.test.rules.user_rule import UserRule + +session = zeeguu.core.model.db.session + + +class FriendTest(ModelTestMixIn): + def setUp(self): + super().setUp() + self.user = UserRule().user + self.friend_user = UserRule().user + self.friendship = Friend(user_id=self.user.id, friend_id=self.friend_user.id) + session.add(self.friendship) + session.commit() + + def _set_last_practiced(self, user, practiced_at): + user_language = UserLanguage.find_or_create(session, user, user.learned_language) + user_language.last_practiced = practiced_at + session.add(user_language) + session.commit() + + def test_update_friend_streak_multiple_friends(self): + from zeeguu.core.model.language import Language + from zeeguu.core.model.user_language import UserLanguage + from zeeguu.core.model.friend import Friend + + # Create a language + lang = Language.find_or_create("en") + + # Create three users + user1 = self.user + user2 = UserRule().user + user3 = UserRule().user + + # Set up friendships: user1 ↔ user2, user1 ↔ user3 + friendship1 = Friend(user_id=user1.id, friend_id=user2.id) + friendship2 = Friend(user_id=user1.id, friend_id=user3.id) + session.add(friendship1) + session.add(friendship2) + session.commit() + + # Practice today for user1, user2, user3 + now = datetime.now() + ul1 = UserLanguage.find_or_create(session, user1, lang) + ul2 = UserLanguage.find_or_create(session, user2, lang) + ul3 = UserLanguage.find_or_create(session, user3, lang) + ul1.last_practiced = now + ul2.last_practiced = now + ul3.last_practiced = now + session.add(ul1) + session.add(ul2) + session.add(ul3) + session.commit() + + # Update streak for user1 (should update both friendships) + ul1.update_streak_if_needed(user1, session) + + # Refresh friendships from DB + session.refresh(friendship1) + session.refresh(friendship2) + + assert friendship1.friend_streak == 1 + assert friendship2.friend_streak == 1 + + def test_update_friend_streak_does_not_reset_without_user_languages(self): + self.friendship.friend_streak = 7 + session.add(self.friendship) + session.commit() + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 7 + + def test_update_friend_streak_sets_to_one_if_both_practiced_today(self): + now = datetime.now() + self._set_last_practiced(self.user, now) + self._set_last_practiced(self.friend_user, now) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 1 + + def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=1)) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 0 + + def test_update_friend_streak_twice_only_increase_by_one(self): + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=1)) + + self.friendship.update_friend_streak() + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 0 + + def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): + # Start from an active streak to verify reset behavior. + self.friendship.friend_streak = 4 + session.add(self.friendship) + session.commit() + + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=2)) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 0 + assert self.friendship.friend_streak_last_updated is not None + + + def test_update_friend_streak_uses_learned_language(self): + # Setup: user and friend each have two languages, but only learned_language should count + from zeeguu.core.model.language import Language + + # Create two languages + lang1 = Language.find_or_create("en") + lang2 = Language.find_or_create("de") + + # Assign learned_language for both users + self.user.learned_language = lang1 + self.friend_user.learned_language = lang2 + session.add(self.user) + session.add(self.friend_user) + session.commit() + + # Practice in learned_language for both users + user_lang = UserLanguage.find_or_create(session, self.user, lang1) + friend_lang = UserLanguage.find_or_create(session, self.friend_user, lang2) + user_lang.last_practiced = datetime.now() + friend_lang.last_practiced = datetime.now() + session.add(user_lang) + session.add(friend_lang) + session.commit() + + # Add practice in a non-learned language (should not affect streak) + user_lang_other = UserLanguage.find_or_create(session, self.user, lang2) + friend_lang_other = UserLanguage.find_or_create(session, self.friend_user, lang1) + user_lang_other.last_practiced = datetime.now() - timedelta(days=5) + friend_lang_other.last_practiced = datetime.now() - timedelta(days=5) + session.add(user_lang_other) + session.add(friend_lang_other) + session.commit() + + # Act: update streak + self.friendship.update_friend_streak() + + # Assert: streak is 1, only learned_language practice is counted + assert self.friendship.friend_streak == 1 + diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 1cf132cf..fd5e04e4 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -17,6 +17,7 @@ def _feature_map(): "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, "show_non_simplified_articles": _show_non_simplified_articles, + "gamification": _gamification } @@ -90,8 +91,41 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - for user_cohort in user.cohorts: if user_cohort.cohort_id in COHORTS_WITH_HIDDEN_RECOMMENDATIONS: return True return False + +# Gamification feature flag logic +from sqlalchemy.exc import NoResultFound + +from .model.user import User +from .model.cohort import Cohort +from datetime import datetime, date +GAMIFICATION_START_DATE = date(2026, 4, 1) +def _gamification(user: User): + """ + Enable general gamification features for users whose invitation with the gamification invite code, + or who are in the gamification cohort. This includes features like badges, friends, and leaderboards. + """ + + GAMIFICATION_INVITE_CODE = "CD8HGKKJ" + if user.is_dev: + return True + + # Invitation code can be None + invitation_code = user.invitation_code or "" + if invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + return True + + # Find gamification cohort by invite code, if it exists. + try: + gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + except NoResultFound: + gamification_cohort = None + + if gamification_cohort and user.is_member_of_cohort(gamification_cohort.id): + return True + + # Disabled for everyone else + return False diff --git a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py index 027eae79..7b7309dd 100644 --- a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py +++ b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py @@ -1,6 +1,8 @@ from .basicSR import ONE_DAY, BasicSRSchedule from datetime import datetime, timedelta +from ...model import UserWord + MAX_LEVEL = 4 # Minimum delay before a word reappears in exercises. @@ -61,6 +63,10 @@ def update_schedule(self, db_session, correctness, exercise_time: datetime = Non else: self.set_meaning_as_learned(db_session) + from zeeguu.core.badges.badge_progress import BadgeCode, increment_badge_progress + user_id = self.user_word.user.id + increment_badge_progress(db_session, BadgeCode.LEARNED_WORDS, user_id) + db_session.commit() # we simply return because the self object will have been deleted inside of the above call return else: