From a0ff6620951c579260a5fa17ab1d6ca1c1c2227f Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 09:44:30 +0100 Subject: [PATCH 01/43] Working on leaderboards --- zeeguu/api/endpoints/friends.py | 122 ++++++++++++++++++++++++++++++++ zeeguu/core/model/friend.py | 107 +++++++++++++++++++++++++++- 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 10869702..0d6fd261 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -25,6 +25,70 @@ def get_friends(): return json_result(result) +# --------------------------------------------------------------------------- +@api.route("/friends_exercise_leaderboard", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def friends_exercise_leaderboard(): + """ + Get exercise leaderboard for the current user and their friends. + + Query params: + limit: Optional positive integer. + """ + # limit_arg = request.args.get("limit") + # limit = None + # if limit_arg is not None: + # try: + # limit = int(limit_arg) + # except ValueError: + # return make_error(400, "limit must be an integer") + + # if limit <= 0: + # return make_error(400, "limit must be greater than 0") + + leaderboard_rows = Friend.exercise_leaderboard(flask.g.user_id) + result = [_serialize_exercise_leaderboard_row(row) for row in leaderboard_rows] + + log( + f"friends_exercise_leaderboard: user_id={flask.g.user_id} rows={len(result)}" + ) + return json_result(result) + + +# --------------------------------------------------------------------------- +@api.route("/friends_read_articles_leaderboard", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def friends_read_articles_leaderboard(): + """ + Get read-articles leaderboard for the current user and their friends. + + Query params: + limit: Optional positive integer. + """ + limit_arg = request.args.get("limit") + limit = 20 + if limit_arg is not None: + try: + limit = int(limit_arg) + except ValueError: + return make_error(400, "limit must be an integer") + + if limit <= 0: + return make_error(400, "limit must be greater than 0") + + leaderboard_rows = Friend.read_articles_leaderboard(flask.g.user_id, limit=limit) + result = [_serialize_read_articles_leaderboard_row(row) for row in leaderboard_rows] + + log( + f"friends_read_articles_leaderboard: user_id={flask.g.user_id} rows={len(result)}" + ) + return json_result(result) + + # --------------------------------------------------------------------------- @api.route("/get_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @@ -251,6 +315,64 @@ def _serialize_users(users: list[User]): return [_serialize_user(user) for user in users] +def _serialize_exercise_leaderboard_row(row): + # SQLAlchemy may return either Row objects (attribute access) or plain tuples, + # depending on query composition/version. Support both shapes safely. + user_id = getattr(row, "user_id", None) + if user_id is None: + user_id = getattr(row, "id", None) + if user_id is None and isinstance(row, tuple): + user_id = row[0] + + name = getattr(row, "name", None) + if name is None and isinstance(row, tuple): + name = row[1] + + username = getattr(row, "username", None) + if username is None and isinstance(row, tuple): + username = row[2] + + session_duration_ms = getattr(row, "session_duration_ms", None) + if session_duration_ms is None and isinstance(row, tuple): + session_duration_ms = row[3] + + return { + "user": { + "id": user_id, + "name": name, + "username": username, + }, + "session_duration_ms": int(session_duration_ms or 0), + } + + +def _serialize_read_articles_leaderboard_row(row): + user_id = getattr(row, "user_id", None) + if user_id is None and isinstance(row, tuple): + user_id = row[0] + + name = getattr(row, "name", None) + if name is None and isinstance(row, tuple): + name = row[1] + + username = getattr(row, "username", None) + if username is None and isinstance(row, tuple): + username = row[2] + + read_articles_count = getattr(row, "read_articles_count", None) + if read_articles_count is None and isinstance(row, tuple): + read_articles_count = row[3] + + return { + "user": { + "id": user_id, + "name": name, + "username": username, + }, + "read_articles_count": int(read_articles_count or 0), + } + + 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 diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 75d4291b..32007f1e 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,4 +1,15 @@ -from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func, or_ +from sqlalchemy import ( + Column, + Integer, + DateTime, + Enum, + ForeignKey, + func, + or_, + and_, + literal, + case, +) 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 @@ -40,6 +51,100 @@ def get_friends(user_id): .all() ) return friends + + @staticmethod + def exercise_leaderboard(user_id: int, limit: int = 20): + """ + Return leaderboard rows for current user and all friends ordered by total + exercise session duration in descending order. + """ + from zeeguu.core.model.user_exercise_session import UserExerciseSession + + # For each friendship row touching this user, select "the other user". + friend_user_ids = ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + ) + + related_user_ids = friend_user_ids.union( + db.session.query(literal(user_id).label("user_id")) + ).subquery() + + total_duration = func.coalesce(func.sum(UserExerciseSession.duration), 0) + + query = ( + db.session.query( + related_user_ids.c.user_id.label("user_id"), + User.name.label("name"), + User.username.label("username"), + total_duration.label("session_duration_ms"), + ) + .join(User, User.id == related_user_ids.c.user_id) + .outerjoin( + UserExerciseSession, + UserExerciseSession.user_id == related_user_ids.c.user_id, + ) + .group_by(related_user_ids.c.user_id, User.name, User.username) + .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + ) + + if limit is not None: + query = query.limit(limit) + + return query.all() + + @staticmethod + def read_articles_leaderboard(user_id: int, limit: int = 20): + """ + Return leaderboard rows for current user and all friends ordered by + number of opened articles in descending order. + """ + from zeeguu.core.model.user_article import UserArticle + + friend_user_ids = ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + ) + + related_user_ids = friend_user_ids.union( + db.session.query(literal(user_id).label("user_id")) + ).subquery() + + opened_articles_count = func.count(UserArticle.id) + + query = ( + db.session.query( + related_user_ids.c.user_id.label("user_id"), + User.name.label("name"), + User.username.label("username"), + opened_articles_count.label("read_articles_count"), + ) + .join(User, User.id == related_user_ids.c.user_id) + .outerjoin( + UserArticle, + and_( + UserArticle.user_id == related_user_ids.c.user_id, + UserArticle.opened.isnot(None), + ), + ) + .group_by(related_user_ids.c.user_id, User.name, User.username) + .order_by(opened_articles_count.desc(), related_user_ids.c.user_id.asc()) + ) + + if limit is not None: + query = query.limit(limit) + + return query.all() @classmethod def remove_friendship(cls, user1_id: int, user2_id: int)->bool: From 8ee7ffc86e25674cc4f8f359616ff0d394a72b0d Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 09:58:11 +0100 Subject: [PATCH 02/43] working on the leaderboards --- zeeguu/api/endpoints/friends.py | 59 +++++++++++++++++++++++++++++++++ zeeguu/core/model/friend.py | 45 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 0d6fd261..6a3aaf9c 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -89,6 +89,38 @@ def friends_read_articles_leaderboard(): return json_result(result) +# --------------------------------------------------------------------------- +@api.route("/friends_reading_sessions_leaderboard", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def friends_reading_sessions_leaderboard(): + """ + Get reading sessions leaderboard for the current user and their friends. + + Query params: + limit: Optional positive integer. + """ + limit_arg = request.args.get("limit") + limit = 20 + if limit_arg is not None: + try: + limit = int(limit_arg) + except ValueError: + return make_error(400, "limit must be an integer") + + if limit <= 0: + return make_error(400, "limit must be greater than 0") + + leaderboard_rows = Friend.reading_sessions_leaderboard(flask.g.user_id, limit=limit) + result = [_serialize_reading_sessions_leaderboard_row(row) for row in leaderboard_rows] + + log( + f"friends_reading_sessions_leaderboard: user_id={flask.g.user_id} rows={len(result)}" + ) + return json_result(result) + + # --------------------------------------------------------------------------- @api.route("/get_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @@ -373,6 +405,33 @@ def _serialize_read_articles_leaderboard_row(row): } +def _serialize_reading_sessions_leaderboard_row(row): + user_id = getattr(row, "user_id", None) + if user_id is None and isinstance(row, tuple): + user_id = row[0] + + name = getattr(row, "name", None) + if name is None and isinstance(row, tuple): + name = row[1] + + username = getattr(row, "username", None) + if username is None and isinstance(row, tuple): + username = row[2] + + session_duration_ms = getattr(row, "session_duration_ms", None) + if session_duration_ms is None and isinstance(row, tuple): + session_duration_ms = row[3] + + return { + "user": { + "id": user_id, + "name": name, + "username": username, + }, + "session_duration_ms": int(session_duration_ms or 0), + } + + 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 diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 32007f1e..5fa20676 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -145,6 +145,51 @@ def read_articles_leaderboard(user_id: int, limit: int = 20): query = query.limit(limit) return query.all() + + @staticmethod + def reading_sessions_leaderboard(user_id: int, limit: int = 20): + """ + Return leaderboard rows for current user and all friends ordered by total + reading session duration in descending order. + """ + from zeeguu.core.model.user_reading_session import UserReadingSession + + friend_user_ids = ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + ) + + related_user_ids = friend_user_ids.union( + db.session.query(literal(user_id).label("user_id")) + ).subquery() + + total_duration = func.coalesce(func.sum(UserReadingSession.duration), 0) + + query = ( + db.session.query( + related_user_ids.c.user_id.label("user_id"), + User.name.label("name"), + User.username.label("username"), + total_duration.label("session_duration_ms"), + ) + .join(User, User.id == related_user_ids.c.user_id) + .outerjoin( + UserReadingSession, + UserReadingSession.user_id == related_user_ids.c.user_id, + ) + .group_by(related_user_ids.c.user_id, User.name, User.username) + .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + ) + + if limit is not None: + query = query.limit(limit) + + return query.all() @classmethod def remove_friendship(cls, user1_id: int, user2_id: int)->bool: From 4a52516ef68539d161a7dd4bf7ab3128b64acd3d Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 11:19:31 +0100 Subject: [PATCH 03/43] Fake user and reading, articles and reading sessions --- tools/generate_fake_articles.py | 132 ++++++++++++++++++++++++ tools/generate_fake_reading_sessions.py | 67 ++++++++++++ tools/generate_fake_users.py | 62 +++++++++++ zeeguu/api/endpoints/friends.py | 121 +++++++++++++++------- zeeguu/core/model/friend.py | 57 ++++++++-- 5 files changed, 392 insertions(+), 47 deletions(-) create mode 100644 tools/generate_fake_articles.py create mode 100644 tools/generate_fake_reading_sessions.py create mode 100644 tools/generate_fake_users.py diff --git a/tools/generate_fake_articles.py b/tools/generate_fake_articles.py new file mode 100644 index 00000000..c3b80936 --- /dev/null +++ b/tools/generate_fake_articles.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +import sys +import os +import random +from datetime import datetime, timedelta + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# REQUIRED: Initialize Flask app context for database access +from zeeguu.api.app import create_app +from zeeguu.core.model import db +app = create_app() +app.app_context().push() + +from zeeguu.core.model.article import Article +from zeeguu.core.model.language import Language +from zeeguu.core.model.url import Url +from zeeguu.core.model.domain_name import DomainName +from zeeguu.core.model.source import Source +from zeeguu.core.model.source_text import SourceText +from zeeguu.core.model.source_type import SourceType +import nltk + + +def ensure_nltk_resources(): + required_resources = [ + ("tokenizers/punkt_tab", "punkt_tab"), + ("tokenizers/punkt", "punkt"), + ] + for resource_path, resource_name in required_resources: + try: + nltk.data.find(resource_path) + except LookupError: + nltk.download(resource_name, quiet=True) + +# Helper functions for fake data +def random_title(): + titles = [ + "Breaking News: AI Revolutionizes Tech", + "10 Tips for Learning Languages Fast", + "The Secret Life of Otters", + "How to Cook the Perfect Pasta", + "Exploring the Wonders of Space", + "The Rise of Electric Vehicles", + "Why Reading is Good for You", + "A Guide to Mindfulness Meditation", + "The History of the Internet", + "Traveling on a Budget: Top Destinations" + ] + return random.choice(titles) + f" #{random.randint(1, 10000)}" + +def random_authors(): + first = ["Alex", "Sam", "Jordan", "Taylor", "Morgan", "Casey", "Jamie", "Robin", "Drew", "Avery"] + last = ["Smith", "Johnson", "Lee", "Brown", "Garcia", "Martinez", "Davis", "Clark", "Lewis", "Walker"] + return f"{random.choice(first)} {random.choice(last)}" + +def random_content(): + sentences = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Vestibulum ac diam sit amet quam vehicula elementum.", + "Curabitur non nulla sit amet nisl tempus convallis.", + "Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus.", + "Pellentesque in ipsum id orci porta dapibus.", + "Proin eget tortor risus.", + "Nulla porttitor accumsan tincidunt.", + "Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a.", + "Quisque velit nisi, pretium ut lacinia in, elementum id enim.", + "Donec sollicitudin molestie malesuada." + ] + return " ".join(random.choices(sentences, k=random.randint(10, 30))) + +def random_url(): + domains = ["example.com", "news.com", "blog.org", "site.net", "demo.io"] + paths = ["/article", "/news", "/post", "/story", "/feature"] + return f"https://{random.choice(domains)}{random.choice(paths)}/{random.randint(1000,99999)}" + + + +def main(): + ensure_nltk_resources() + num_articles = 100 + created = 0 + for _ in range(num_articles): + title = random_title() + authors = random_authors() + content = random_content() + url_str = random_url() + domain = DomainName.get_domain(url_str) + domain_obj = DomainName.query.filter_by(domain_name=domain).first() + if not domain_obj: + domain_obj = DomainName(url_str) + db.session.add(domain_obj) + db.session.commit() + url_obj = Url(url_str, title, domain_obj) + db.session.add(url_obj) + db.session.commit() + language = Language.find_or_create('en') + # print(f"Selected language: {language}, code: {getattr(language, 'code', None)}") + source_text = SourceText.find_or_create(db.session, content) + source_type = SourceType.query.filter_by(type=SourceType.ARTICLE).first() + if not source_type: + source_type = SourceType(SourceType.ARTICLE) + db.session.add(source_type) + db.session.commit() + source = Source.find_or_create(db.session, content, source_type, language, broken=0) + summary = content[:200] + published_time = datetime.now() - timedelta(days=random.randint(0, 30)) + article = Article( + url=url_obj, + title=title, + authors=authors, + source=source, + summary=summary, + published_time=published_time, + feed=None, + language=language, + htmlContent=content, + uploader=None, + found_by_user=0, + broken=0, + deleted=0, + video=0, + img_url=None + ) + db.session.add(article) + created += 1 + db.session.commit() + print(f"Created {created} fake articles.") + +if __name__ == "__main__": + main() diff --git a/tools/generate_fake_reading_sessions.py b/tools/generate_fake_reading_sessions.py new file mode 100644 index 00000000..50c48ae1 --- /dev/null +++ b/tools/generate_fake_reading_sessions.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +import sys +import os +import random +from datetime import datetime, timedelta + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# REQUIRED: Initialize Flask app context for database access +from zeeguu.api.app import create_app +from zeeguu.core.model import db +app = create_app() +app.app_context().push() + +from zeeguu.core.model.user import User +from zeeguu.core.model.article import Article +from zeeguu.core.model.user_reading_session import UserReadingSession + +def get_random_user(): + users = User.query.all() + return random.choice(users) if users else None + +def get_random_article(): + articles = Article.query.all() + return random.choice(articles) if articles else None + +def random_datetime_in_last_month(): + now = datetime.now() + start = now - timedelta(days=30) + return start + timedelta(seconds=random.randint(0, int((now - start).total_seconds()))) + +READING_SOURCES = ['extension', 'web'] +PLATFORMS = [1, 2, 3] # Example platform codes; adjust as needed + +def main(): + num_sessions = 50 + created = 0 + for _ in range(num_sessions): + # user = get_random_user() + user = User.find_by_id(5) + article = get_random_article() + if not user or not article: + print("Not enough users or articles in the DB.") + break + start_time = random_datetime_in_last_month() + duration = random.randint(1, 30) * 60 * 1000 # 1-30 minutes in ms + last_action_time = start_time + timedelta(milliseconds=duration) + reading_source = random.choice(READING_SOURCES) + platform = random.choice(PLATFORMS) + session = UserReadingSession( + user_id=user.id, + article_id=article.id, + current_time=start_time, + reading_source=reading_source, + platform=platform + ) + session.duration = duration + session.last_action_time = last_action_time + session.is_active = False + db.session.add(session) + created += 1 + db.session.commit() + print(f"Created {created} UserReadingSession records.") + +if __name__ == "__main__": + main() diff --git a/tools/generate_fake_users.py b/tools/generate_fake_users.py new file mode 100644 index 00000000..d45063ac --- /dev/null +++ b/tools/generate_fake_users.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +import sys +import os +import random +from datetime import datetime + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# REQUIRED: Initialize Flask app context for database access +from zeeguu.api.app import create_app +from zeeguu.core.model import db +app = create_app() +app.app_context().push() + +from zeeguu.core.model.user import User +from zeeguu.core.model.language import Language + +def random_email(): + domains = ["example.com", "test.org", "mail.com", "demo.net"] + name = f"user{random.randint(1000, 9999)}" + return f"{name}@{random.choice(domains)}" + +def random_name(): + first_names = ["Alex", "Sam", "Jordan", "Taylor", "Morgan", "Casey", "Jamie", "Robin", "Drew", "Avery"] + last_names = ["Smith", "Johnson", "Lee", "Brown", "Garcia", "Martinez", "Davis", "Clark", "Lewis", "Walker"] + return f"{random.choice(first_names)} {random.choice(last_names)}" + +def random_username(): + adjectives = User.ADJECTIVES + nouns = User.NOUNS + return f"{random.choice(adjectives)}_{random.choice(nouns)}_{random.randint(1,9999)}" + +def random_language(): + codes = list(Language.LANGUAGE_NAMES.keys()) + return Language.find_or_create(random.choice(codes)) + +def main(): + num_users = 100 + created = 0 + for _ in range(num_users): + email = random_email() + name = random_name() + username = random_username() + password = "testpassword" + learned_language = random_language() + native_language = random_language() + user = User( + email=email, + name=name, + password=password, + username=username, + learned_language=learned_language, + native_language=native_language + ) + db.session.add(user) + created += 1 + db.session.commit() + print(f"Created {created} fake users.") + +if __name__ == "__main__": + main() diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 6a3aaf9c..be088e98 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,4 +1,5 @@ import flask +from datetime import datetime from flask import request from zeeguu.core.model import User from zeeguu.core.model.friend import Friend @@ -37,18 +38,16 @@ def friends_exercise_leaderboard(): Query params: limit: Optional positive integer. """ - # limit_arg = request.args.get("limit") - # limit = None - # if limit_arg is not None: - # try: - # limit = int(limit_arg) - # except ValueError: - # return make_error(400, "limit must be an integer") - - # if limit <= 0: - # return make_error(400, "limit must be greater than 0") - - leaderboard_rows = Friend.exercise_leaderboard(flask.g.user_id) + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + leaderboard_rows = Friend.exercise_leaderboard( + flask.g.user_id, + limit=params["limit"], + from_date=params["from_date"], + to_date=params["to_date"], + ) result = [_serialize_exercise_leaderboard_row(row) for row in leaderboard_rows] log( @@ -69,18 +68,16 @@ def friends_read_articles_leaderboard(): Query params: limit: Optional positive integer. """ - limit_arg = request.args.get("limit") - limit = 20 - if limit_arg is not None: - try: - limit = int(limit_arg) - except ValueError: - return make_error(400, "limit must be an integer") - - if limit <= 0: - return make_error(400, "limit must be greater than 0") - - leaderboard_rows = Friend.read_articles_leaderboard(flask.g.user_id, limit=limit) + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + leaderboard_rows = Friend.read_articles_leaderboard( + flask.g.user_id, + limit=params["limit"], + from_date=params["from_date"], + to_date=params["to_date"], + ) result = [_serialize_read_articles_leaderboard_row(row) for row in leaderboard_rows] log( @@ -101,18 +98,16 @@ def friends_reading_sessions_leaderboard(): Query params: limit: Optional positive integer. """ - limit_arg = request.args.get("limit") - limit = 20 - if limit_arg is not None: - try: - limit = int(limit_arg) - except ValueError: - return make_error(400, "limit must be an integer") - - if limit <= 0: - return make_error(400, "limit must be greater than 0") - - leaderboard_rows = Friend.reading_sessions_leaderboard(flask.g.user_id, limit=limit) + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + leaderboard_rows = Friend.reading_sessions_leaderboard( + flask.g.user_id, + limit=params["limit"], + from_date=params["from_date"], + to_date=params["to_date"], + ) result = [_serialize_reading_sessions_leaderboard_row(row) for row in leaderboard_rows] log( @@ -446,4 +441,56 @@ def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: if sender_id == receiver_id: return 422, "cannot send friend request to yourself" - return 200, "ok" \ No newline at end of file + return 200, "ok" + + +def _parse_leaderboard_query_params(): + """ + Parse common leaderboard query params: + limit: positive integer + from_date: ISO datetime string (optional) + to_date: ISO datetime string (optional) + """ + limit = 20 + limit_arg = request.args.get("limit") + if limit_arg is not None: + try: + limit = int(limit_arg) + except ValueError: + return None, make_error(400, "limit must be an integer") + + if limit <= 0: + return None, make_error(400, "limit must be greater than 0") + + from_date_str = request.args.get("from_date") + to_date_str = request.args.get("to_date") + + from_date = None + to_date = None + + if from_date_str: + from_date, error = _parse_iso_datetime(from_date_str, "from_date") + if error: + return None, error + + if to_date_str: + to_date, error = _parse_iso_datetime(to_date_str, "to_date") + if error: + return None, error + + if from_date and to_date and from_date > to_date: + return None, make_error(400, "from_date must be before or equal to to_date") + + return { + "limit": limit, + "from_date": from_date, + "to_date": to_date, + }, None + + +def _parse_iso_datetime(value: str, param_name: str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + return parsed, None + except ValueError: + return None, make_error(400, f"{param_name} must be a valid ISO datetime") \ No newline at end of file diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 5fa20676..51fe7df9 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -53,7 +53,12 @@ def get_friends(user_id): return friends @staticmethod - def exercise_leaderboard(user_id: int, limit: int = 20): + def exercise_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, + ): """ Return leaderboard rows for current user and all friends ordered by total exercise session duration in descending order. @@ -87,7 +92,15 @@ def exercise_leaderboard(user_id: int, limit: int = 20): .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserExerciseSession, - UserExerciseSession.user_id == related_user_ids.c.user_id, + and_( + UserExerciseSession.user_id == related_user_ids.c.user_id, + UserExerciseSession.start_time >= from_date + if from_date is not None + else True, + UserExerciseSession.start_time <= to_date + if to_date is not None + else True, + ), ) .group_by(related_user_ids.c.user_id, User.name, User.username) .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) @@ -99,10 +112,15 @@ def exercise_leaderboard(user_id: int, limit: int = 20): return query.all() @staticmethod - def read_articles_leaderboard(user_id: int, limit: int = 20): + def read_articles_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, + ): """ Return leaderboard rows for current user and all friends ordered by - number of opened articles in descending order. + number of completed articles in descending order. """ from zeeguu.core.model.user_article import UserArticle @@ -120,25 +138,31 @@ def read_articles_leaderboard(user_id: int, limit: int = 20): db.session.query(literal(user_id).label("user_id")) ).subquery() - opened_articles_count = func.count(UserArticle.id) + completed_articles_count = func.count(UserArticle.id) query = ( db.session.query( related_user_ids.c.user_id.label("user_id"), User.name.label("name"), User.username.label("username"), - opened_articles_count.label("read_articles_count"), + completed_articles_count.label("read_articles_count"), ) .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserArticle, and_( UserArticle.user_id == related_user_ids.c.user_id, - UserArticle.opened.isnot(None), + UserArticle.completed_at.isnot(None), + UserArticle.completed_at >= from_date + if from_date is not None + else True, + UserArticle.completed_at <= to_date + if to_date is not None + else True, ), ) .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(opened_articles_count.desc(), related_user_ids.c.user_id.asc()) + .order_by(completed_articles_count.desc(), related_user_ids.c.user_id.asc()) ) if limit is not None: @@ -147,7 +171,12 @@ def read_articles_leaderboard(user_id: int, limit: int = 20): return query.all() @staticmethod - def reading_sessions_leaderboard(user_id: int, limit: int = 20): + def reading_sessions_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, + ): """ Return leaderboard rows for current user and all friends ordered by total reading session duration in descending order. @@ -180,7 +209,15 @@ def reading_sessions_leaderboard(user_id: int, limit: int = 20): .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserReadingSession, - UserReadingSession.user_id == related_user_ids.c.user_id, + and_( + UserReadingSession.user_id == related_user_ids.c.user_id, + UserReadingSession.start_time >= from_date + if from_date is not None + else True, + UserReadingSession.start_time <= to_date + if to_date is not None + else True, + ), ) .group_by(related_user_ids.c.user_id, User.name, User.username) .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) From 9e3d2ef0f5d8b33130b3f741a1c87c17289e85e6 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 11:49:14 +0100 Subject: [PATCH 04/43] Fixed the generate_fake_artiles to also inlcude UserArticle --- tools/generate_fake_articles.py | 71 +++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/tools/generate_fake_articles.py b/tools/generate_fake_articles.py index c3b80936..dc9b99b1 100644 --- a/tools/generate_fake_articles.py +++ b/tools/generate_fake_articles.py @@ -20,6 +20,9 @@ from zeeguu.core.model.source import Source from zeeguu.core.model.source_text import SourceText from zeeguu.core.model.source_type import SourceType +from zeeguu.core.model.user import User +from zeeguu.core.model.user_article import UserArticle +from zeeguu.core.model.friend import Friend import nltk @@ -76,11 +79,69 @@ def random_url(): return f"https://{random.choice(domains)}{random.choice(paths)}/{random.randint(1000,99999)}" +def random_datetime_in_last_month(): + now = datetime.now() + start = now - timedelta(days=30) + return start + timedelta( + seconds=random.randint(0, int((now - start).total_seconds())) + ) + + +def get_target_users_for_user_article(target_user_id=5, fallback_count=5): + target_user = User.query.filter_by(id=target_user_id).first() + if not target_user: + return User.query.order_by(User.id.desc()).limit(fallback_count).all() + + users = [target_user] + users.extend(Friend.get_friends(target_user_id)) + + unique_by_id = {} + for user in users: + unique_by_id[user.id] = user + + return list(unique_by_id.values()) + + +def create_fake_user_article_rows(articles, target_user_id=5): + users = get_target_users_for_user_article(target_user_id=target_user_id) + if not users or not articles: + return 0 + + created = 0 + for article in articles: + max_users_for_article = min(len(users), random.randint(1, 4)) + selected_users = random.sample(users, max_users_for_article) + + for user in selected_users: + completed_at = random_datetime_in_last_month() + opened = completed_at - timedelta(minutes=random.randint(1, 15)) + liked = random.random() < 0.45 + reading_completion = round(random.uniform(0.9, 1.0), 2) + + existing = UserArticle.find(user, article) + if existing: + continue + + ua = UserArticle( + user=user, + article=article, + opened=opened, + liked=liked, + reading_completion=reading_completion, + completed_at=completed_at, + ) + db.session.add(ua) + created += 1 + + db.session.commit() + return created + + def main(): ensure_nltk_resources() num_articles = 100 - created = 0 + created_articles = [] for _ in range(num_articles): title = random_title() authors = random_authors() @@ -124,9 +185,13 @@ def main(): img_url=None ) db.session.add(article) - created += 1 + created_articles.append(article) db.session.commit() - print(f"Created {created} fake articles.") + + created_user_articles = create_fake_user_article_rows(created_articles) + + print(f"Created {len(created_articles)} fake articles.") + print(f"Created {created_user_articles} fake user-article rows.") if __name__ == "__main__": main() From 08b0e4c02aeb5b1526cab4fc437d6c53b20e1791 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 12:33:15 +0100 Subject: [PATCH 05/43] Exercises completed leaderboard endpoint --- zeeguu/api/endpoints/friends.py | 57 +++++++++++++++++++++++++++++++++ zeeguu/core/model/friend.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index be088e98..aaf9154d 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -56,6 +56,36 @@ def friends_exercise_leaderboard(): return json_result(result) +# --------------------------------------------------------------------------- +@api.route("/friends_exercises_done_leaderboard", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def friends_exercises_done_leaderboard(): + """ + Get exercises-done leaderboard for the current user and their friends. + + Query params: + limit: Optional positive integer. + """ + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + leaderboard_rows = Friend.exercises_done_leaderboard( + flask.g.user_id, + limit=params["limit"], + from_date=params["from_date"], + to_date=params["to_date"], + ) + result = [_serialize_exercises_done_leaderboard_row(row) for row in leaderboard_rows] + + log( + f"friends_exercises_done_leaderboard: user_id={flask.g.user_id} rows={len(result)}" + ) + return json_result(result) + + # --------------------------------------------------------------------------- @api.route("/friends_read_articles_leaderboard", methods=["GET"]) # --------------------------------------------------------------------------- @@ -427,6 +457,33 @@ def _serialize_reading_sessions_leaderboard_row(row): } +def _serialize_exercises_done_leaderboard_row(row): + user_id = getattr(row, "user_id", None) + if user_id is None and isinstance(row, tuple): + user_id = row[0] + + name = getattr(row, "name", None) + if name is None and isinstance(row, tuple): + name = row[1] + + username = getattr(row, "username", None) + if username is None and isinstance(row, tuple): + username = row[2] + + exercises_done_count = getattr(row, "exercises_done_count", None) + if exercises_done_count is None and isinstance(row, tuple): + exercises_done_count = row[3] + + return { + "user": { + "id": user_id, + "name": name, + "username": username, + }, + "exercises_done_count": int(exercises_done_count or 0), + } + + 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 diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 51fe7df9..63c8fcbc 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -227,6 +227,62 @@ def reading_sessions_leaderboard( query = query.limit(limit) return query.all() + + @staticmethod + def exercises_done_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, + ): + """ + Return leaderboard rows for current user and all friends ordered by + number of exercises done in descending order. + """ + from zeeguu.core.model.exercise import Exercise + from zeeguu.core.model.user_word import UserWord + + friend_user_ids = ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + ) + + related_user_ids = friend_user_ids.union( + db.session.query(literal(user_id).label("user_id")) + ).subquery() + + exercises_done_count = func.count(Exercise.id) + + query = ( + db.session.query( + related_user_ids.c.user_id.label("user_id"), + User.name.label("name"), + User.username.label("username"), + exercises_done_count.label("exercises_done_count"), + ) + .join(User, User.id == related_user_ids.c.user_id) + .outerjoin(UserWord, UserWord.user_id == related_user_ids.c.user_id) + .outerjoin( + Exercise, + and_( + Exercise.user_word_id == UserWord.id, + Exercise.time >= from_date if from_date is not None else True, + Exercise.time <= to_date if to_date is not None else True, + ), + ) + .group_by(related_user_ids.c.user_id, User.name, User.username) + .order_by(exercises_done_count.desc(), related_user_ids.c.user_id.asc()) + ) + + if limit is not None: + query = query.limit(limit) + + return query.all() @classmethod def remove_friendship(cls, user1_id: int, user2_id: int)->bool: From 510fbd546d5290d93fcaeec4238cceb99772792d Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 19 Mar 2026 12:50:42 +0100 Subject: [PATCH 06/43] generate fake exercises --- tools/generate_fake_exercises.py | 260 +++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tools/generate_fake_exercises.py diff --git a/tools/generate_fake_exercises.py b/tools/generate_fake_exercises.py new file mode 100644 index 00000000..67d7bacc --- /dev/null +++ b/tools/generate_fake_exercises.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +import argparse +import os +import random +import sys +from datetime import datetime, timedelta +from sqlalchemy import text + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# REQUIRED: Initialize Flask app context for database access +from zeeguu.api.app import create_app +from zeeguu.core.model import db + +app = create_app() +app.app_context().push() + +from zeeguu.core.model.user import User +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.user_word import UserWord +from zeeguu.core.model.meaning import Meaning +from zeeguu.core.model.phrase import Phrase +from zeeguu.core.model.language import Language +from zeeguu.core.model.exercise import Exercise +from zeeguu.core.model.exercise_outcome import ExerciseOutcome +from zeeguu.core.model.exercise_source import ExerciseSource + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Generate fake Exercise rows for user and friend leaderboards." + ) + parser.add_argument( + "--target-user-id", + type=int, + default=5, + help="User ID whose friend network should receive exercise rows (default: 5).", + ) + parser.add_argument( + "--total-exercises", + type=int, + default=250, + help="Total number of fake Exercise rows to create (default: 250).", + ) + parser.add_argument( + "--days", + type=int, + default=30, + help="Spread fake exercise timestamps in the last N days (default: 30).", + ) + return parser.parse_args() + + +def random_datetime_in_last_days(days): + now = datetime.now() + start = now - timedelta(days=days) + delta_seconds = int((now - start).total_seconds()) + return start + timedelta(seconds=random.randint(0, max(delta_seconds, 1))) + + +def get_target_users(target_user_id): + target_user = User.query.filter_by(id=target_user_id).first() + if not target_user: + print(f"Warning: target user {target_user_id} not found. Falling back to recent users.") + fallback_users = User.query.order_by(User.id.desc()).limit(5).all() + return fallback_users + + users = [target_user] + users.extend(Friend.get_friends(target_user_id)) + + unique_users = {} + for user in users: + unique_users[user.id] = user + + return list(unique_users.values()) + + +def ensure_meanings_exist(): + existing_count = db.session.query(Meaning.id).count() + if existing_count > 0: + return + + bootstrap_pairs = [ + ("book", "en", "Buch", "de"), + ("house", "en", "Haus", "de"), + ("water", "en", "Wasser", "de"), + ("friend", "en", "Freund", "de"), + ("school", "en", "Schule", "de"), + ("city", "en", "Stadt", "de"), + ("sun", "en", "Sonne", "de"), + ("food", "en", "Essen", "de"), + ("music", "en", "Musik", "de"), + ("train", "en", "Zug", "de"), + ("dog", "en", "Hund", "de"), + ("cat", "en", "Katze", "de"), + ] + + def get_or_create_phrase(content, lang_code): + language = Language.find_or_create(lang_code) + phrase = ( + Phrase.query.filter(Phrase.content == content) + .filter(Phrase.language_id == language.id) + .first() + ) + if phrase: + return phrase + + db.session.execute( + text( + """ + INSERT INTO phrase (language_id, content) + VALUES (:language_id, :content) + """ + ), + {"language_id": language.id, "content": content}, + ) + db.session.commit() + + return ( + Phrase.query.filter(Phrase.content == content) + .filter(Phrase.language_id == language.id) + .first() + ) + + for origin, origin_lang, translation, translation_lang in bootstrap_pairs: + origin_phrase = get_or_create_phrase(origin, origin_lang) + translation_phrase = get_or_create_phrase(translation, translation_lang) + + exists = ( + Meaning.query.filter(Meaning.origin_id == origin_phrase.id) + .filter(Meaning.translation_id == translation_phrase.id) + .first() + ) + if exists: + continue + + meaning = Meaning(origin=origin_phrase, translation=translation_phrase) + db.session.add(meaning) + + db.session.commit() + + +def ensure_user_words(users): + """ + Ensure every target user has at least a few UserWord rows so Exercise rows + can be linked through Exercise.user_word_id. + """ + ensure_meanings_exist() + + all_meaning_ids = [m.id for m in db.session.query(Meaning.id).all()] + if not all_meaning_ids: + raise RuntimeError("No Meaning rows found. Cannot create UserWord/Exercise data.") + + created_user_words = 0 + user_to_words = {} + + for user in users: + words = UserWord.query.filter_by(user_id=user.id).all() + if not words: + sample_size = min(8, len(all_meaning_ids)) + sampled_meaning_ids = random.sample(all_meaning_ids, sample_size) + for meaning_id in sampled_meaning_ids: + meaning = Meaning.query.filter_by(id=meaning_id).first() + if not meaning: + continue + uw = UserWord(user=user, meaning=meaning) + db.session.add(uw) + created_user_words += 1 + db.session.commit() + words = UserWord.query.filter_by(user_id=user.id).all() + + user_to_words[user.id] = words + + return user_to_words, created_user_words + + +def ensure_exercise_metadata(): + outcome_pool = [ + ExerciseOutcome.CORRECT, + "W", + "HC", + "TC", + ExerciseOutcome.TOO_EASY, + ] + + outcomes = {} + for outcome_name in outcome_pool: + outcomes[outcome_name] = ExerciseOutcome.find_or_create(db.session, outcome_name) + + source = ExerciseSource.find_or_create( + db.session, ExerciseSource.TOP_BOOKMARKS_MINI_EXERCISE + ) + return outcomes, source + + +def generate_exercises(users, user_to_words, outcomes, source, total_exercises, days): + created = 0 + + weighted_outcomes = [ + ExerciseOutcome.CORRECT, + ExerciseOutcome.CORRECT, + ExerciseOutcome.CORRECT, + "W", + "HC", + "TC", + ExerciseOutcome.TOO_EASY, + ] + + for _ in range(total_exercises): + user = random.choice(users) + words = user_to_words.get(user.id, []) + if not words: + continue + + word = random.choice(words) + outcome_name = random.choice(weighted_outcomes) + outcome = outcomes[outcome_name] + + exercise = Exercise( + outcome=outcome, + source=source, + solving_speed=random.randint(1500, 22000), + time=random_datetime_in_last_days(days), + session_id=None, + user_word=word, + feedback="", + ) + db.session.add(exercise) + created += 1 + + db.session.commit() + return created + + +def main(): + args = parse_args() + + users = get_target_users(args.target_user_id) + if not users: + raise RuntimeError("No users available to seed exercise data.") + + user_to_words, created_user_words = ensure_user_words(users) + outcomes, source = ensure_exercise_metadata() + created_exercises = generate_exercises( + users, + user_to_words, + outcomes, + source, + args.total_exercises, + args.days, + ) + + print(f"Target users: {[u.id for u in users]}") + print(f"Created UserWord rows: {created_user_words}") + print(f"Created Exercise rows: {created_exercises}") + + +if __name__ == "__main__": + main() From 9abc893bc13260c50f5289d82177f720ce70d0c9 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 22 Mar 2026 11:03:13 +0100 Subject: [PATCH 07/43] Started generalizing leaderboards --- zeeguu/api/endpoints/friends.py | 276 ++++++-------------------------- zeeguu/core/model/friend.py | 92 ++++------- 2 files changed, 82 insertions(+), 286 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index fb913d28..845b3364 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -12,6 +12,13 @@ from zeeguu.logging import log, warning from . import api +LEADERBOARD_METRICS = { + "exercise_time": Friend.exercise_leaderboard, + "exercises_done": Friend.exercises_done_leaderboard, + "articles_read": Friend.read_articles_leaderboard, + "reading_sessions": Friend.reading_sessions_leaderboard, +} + # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) @@ -38,6 +45,7 @@ def get_friends(user_id: int = None): 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): @@ -50,123 +58,32 @@ def _serialize_user_with_friendship(user: User, friendship): user_data["languages"] = _serialize_user_languages(user) if user else [] return user_data -# --------------------------------------------------------------------------- -@api.route("/friends_exercise_leaderboard", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -@requires_session -def friends_exercise_leaderboard(): - """ - Get exercise leaderboard for the current user and their friends. - Query params: - limit: Optional positive integer. - """ - params, error_response = _parse_leaderboard_query_params() - if error_response: - return error_response - - leaderboard_rows = Friend.exercise_leaderboard( - flask.g.user_id, - limit=params["limit"], - from_date=params["from_date"], - to_date=params["to_date"], - ) - result = [_serialize_exercise_leaderboard_row(row) for row in leaderboard_rows] - - log( - f"friends_exercise_leaderboard: user_id={flask.g.user_id} rows={len(result)}" - ) - return json_result(result) - - -# --------------------------------------------------------------------------- -@api.route("/friends_exercises_done_leaderboard", methods=["GET"]) -# --------------------------------------------------------------------------- +@api.route("/friends_leaderboard", methods=["GET"]) @cross_domain @requires_session -def friends_exercises_done_leaderboard(): - """ - Get exercises-done leaderboard for the current user and their friends. - - Query params: - limit: Optional positive integer. - """ +def friends_leaderboard(): params, error_response = _parse_leaderboard_query_params() if error_response: return error_response - leaderboard_rows = Friend.exercises_done_leaderboard( - flask.g.user_id, - limit=params["limit"], - from_date=params["from_date"], - to_date=params["to_date"], - ) - result = [_serialize_exercises_done_leaderboard_row(row) for row in leaderboard_rows] + metric = LEADERBOARD_METRICS.get(request.args.get("metric")) - log( - f"friends_exercises_done_leaderboard: user_id={flask.g.user_id} rows={len(result)}" - ) - return json_result(result) - - -# --------------------------------------------------------------------------- -@api.route("/friends_read_articles_leaderboard", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -@requires_session -def friends_read_articles_leaderboard(): - """ - Get read-articles leaderboard for the current user and their friends. + if not metric: + return make_error(400, "Invalid leaderboard metric") - Query params: - limit: Optional positive integer. - """ - params, error_response = _parse_leaderboard_query_params() - if error_response: - return error_response - - leaderboard_rows = Friend.read_articles_leaderboard( + rows = metric( flask.g.user_id, limit=params["limit"], from_date=params["from_date"], to_date=params["to_date"], ) - result = [_serialize_read_articles_leaderboard_row(row) for row in leaderboard_rows] - - log( - f"friends_read_articles_leaderboard: user_id={flask.g.user_id} rows={len(result)}" - ) - return json_result(result) + result = [ + _serialize_leaderboard_row(row) + for row in rows + ] -# --------------------------------------------------------------------------- -@api.route("/friends_reading_sessions_leaderboard", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -@requires_session -def friends_reading_sessions_leaderboard(): - """ - Get reading sessions leaderboard for the current user and their friends. - - Query params: - limit: Optional positive integer. - """ - params, error_response = _parse_leaderboard_query_params() - if error_response: - return error_response - - leaderboard_rows = Friend.reading_sessions_leaderboard( - flask.g.user_id, - limit=params["limit"], - from_date=params["from_date"], - to_date=params["to_date"], - ) - result = [_serialize_reading_sessions_leaderboard_row(row) for row in leaderboard_rows] - - log( - f"friends_reading_sessions_leaderboard: user_id={flask.g.user_id} rows={len(result)}" - ) return json_result(result) @@ -184,6 +101,7 @@ def get_friend_requests(): result = [_serialize_friend_request(req) for req in friendRequest] return json_result(result) + # --------------------------------------------------------------------------- @api.route("/get_pending_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @@ -216,7 +134,7 @@ def send_friend_request(): 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: + try: friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) response = _serialize_friend_request(friend_request) return json_result(response) @@ -248,6 +166,7 @@ def delete_friend_request(): is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) return json_result({"success": is_deleted}) + # --------------------------------------------------------------------------- @api.route("/accept_friend_request", methods=["POST"]) # --------------------------------------------------------------------------- @@ -258,7 +177,7 @@ 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 + 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) @@ -270,10 +189,11 @@ def accept_friend_request(): 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"]) # --------------------------------------------------------------------------- @@ -284,7 +204,7 @@ 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 + 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) @@ -295,6 +215,7 @@ def reject_friend_request(): is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) return json_result({"success": is_rejected}) + # --------------------------------------------------------------------------- @api.route("/unfriend", methods=["POST"]) # --------------------------------------------------------------------------- @@ -304,14 +225,14 @@ 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 + 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}") @@ -335,7 +256,7 @@ def search_by_username(username): 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) @@ -359,22 +280,23 @@ def _serialize_friend_request(fr: FriendRequest): 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? + "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? + "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, @@ -384,14 +306,14 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): "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}") @@ -401,129 +323,33 @@ def _serialize_user(user: User): 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 _serialize_exercise_leaderboard_row(row): - # SQLAlchemy may return either Row objects (attribute access) or plain tuples, - # depending on query composition/version. Support both shapes safely. - user_id = getattr(row, "user_id", None) - if user_id is None: - user_id = getattr(row, "id", None) - if user_id is None and isinstance(row, tuple): - user_id = row[0] - - name = getattr(row, "name", None) - if name is None and isinstance(row, tuple): - name = row[1] - - username = getattr(row, "username", None) - if username is None and isinstance(row, tuple): - username = row[2] - - session_duration_ms = getattr(row, "session_duration_ms", None) - if session_duration_ms is None and isinstance(row, tuple): - session_duration_ms = row[3] +def _serialize_leaderboard_row(row): + name = getattr(row, "name", None) or row[0] + username = getattr(row, "username", None) or row[1] + value = getattr(row, "value", None) or row[2] return { "user": { - "id": user_id, "name": name, "username": username, }, - "session_duration_ms": int(session_duration_ms or 0), + "value": value, } -def _serialize_read_articles_leaderboard_row(row): - user_id = getattr(row, "user_id", None) - if user_id is None and isinstance(row, tuple): - user_id = row[0] - - name = getattr(row, "name", None) - if name is None and isinstance(row, tuple): - name = row[1] - - username = getattr(row, "username", None) - if username is None and isinstance(row, tuple): - username = row[2] - - read_articles_count = getattr(row, "read_articles_count", None) - if read_articles_count is None and isinstance(row, tuple): - read_articles_count = row[3] - - return { - "user": { - "id": user_id, - "name": name, - "username": username, - }, - "read_articles_count": int(read_articles_count or 0), - } - - -def _serialize_reading_sessions_leaderboard_row(row): - user_id = getattr(row, "user_id", None) - if user_id is None and isinstance(row, tuple): - user_id = row[0] - - name = getattr(row, "name", None) - if name is None and isinstance(row, tuple): - name = row[1] - - username = getattr(row, "username", None) - if username is None and isinstance(row, tuple): - username = row[2] - - session_duration_ms = getattr(row, "session_duration_ms", None) - if session_duration_ms is None and isinstance(row, tuple): - session_duration_ms = row[3] - - return { - "user": { - "id": user_id, - "name": name, - "username": username, - }, - "session_duration_ms": int(session_duration_ms or 0), - } - - -def _serialize_exercises_done_leaderboard_row(row): - user_id = getattr(row, "user_id", None) - if user_id is None and isinstance(row, tuple): - user_id = row[0] - - name = getattr(row, "name", None) - if name is None and isinstance(row, tuple): - name = row[1] - - username = getattr(row, "username", None) - if username is None and isinstance(row, tuple): - username = row[2] - - exercises_done_count = getattr(row, "exercises_done_count", None) - if exercises_done_count is None and isinstance(row, tuple): - exercises_done_count = row[3] - - return { - "user": { - "id": user_id, - "name": name, - "username": username, - }, - "exercises_done_count": int(exercises_done_count or 0), - } - - -def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: +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 @@ -533,10 +359,10 @@ def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: """ 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" @@ -589,4 +415,4 @@ def _parse_iso_datetime(value: str, param_name: str): parsed = datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) return parsed, None except ValueError: - return None, make_error(400, f"{param_name} must be a valid ISO datetime") \ No newline at end of file + return None, make_error(400, f"{param_name} must be a valid ISO datetime") diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index ba5a6ee0..5e027fcf 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -151,6 +151,23 @@ def are_friends(cls, user1_id: int, user2_id: int) -> bool: return friendship is not None + @staticmethod + def _related_user_ids_subquery(user_id: int): + # For each friendship row touching this user, select "the other user". + return ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + .union( + db.session.query(literal(user_id).label("user_id")) + ) + .subquery() + ) + @staticmethod def exercise_leaderboard( user_id: int, @@ -164,30 +181,17 @@ def exercise_leaderboard( """ from zeeguu.core.model.user_exercise_session import UserExerciseSession - # For each friendship row touching this user, select "the other user". - friend_user_ids = ( - db.session.query( - case( - (Friend.user_id == user_id, Friend.friend_id), - else_=Friend.user_id, - ).label("user_id") - ) - .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - ) - - related_user_ids = friend_user_ids.union( - db.session.query(literal(user_id).label("user_id")) - ).subquery() + related_user_ids = Friend._related_user_ids_subquery(user_id) total_duration = func.coalesce(func.sum(UserExerciseSession.duration), 0) query = ( db.session.query( - related_user_ids.c.user_id.label("user_id"), User.name.label("name"), User.username.label("username"), - total_duration.label("session_duration_ms"), + total_duration.label("value"), ) + .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserExerciseSession, @@ -223,29 +227,17 @@ def read_articles_leaderboard( """ from zeeguu.core.model.user_article import UserArticle - friend_user_ids = ( - db.session.query( - case( - (Friend.user_id == user_id, Friend.friend_id), - else_=Friend.user_id, - ).label("user_id") - ) - .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - ) - - related_user_ids = friend_user_ids.union( - db.session.query(literal(user_id).label("user_id")) - ).subquery() + related_user_ids = Friend._related_user_ids_subquery(user_id) completed_articles_count = func.count(UserArticle.id) query = ( db.session.query( - related_user_ids.c.user_id.label("user_id"), User.name.label("name"), User.username.label("username"), - completed_articles_count.label("read_articles_count"), + completed_articles_count.label("value"), ) + .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserArticle, @@ -282,29 +274,17 @@ def reading_sessions_leaderboard( """ from zeeguu.core.model.user_reading_session import UserReadingSession - friend_user_ids = ( - db.session.query( - case( - (Friend.user_id == user_id, Friend.friend_id), - else_=Friend.user_id, - ).label("user_id") - ) - .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - ) - - related_user_ids = friend_user_ids.union( - db.session.query(literal(user_id).label("user_id")) - ).subquery() + related_user_ids = Friend._related_user_ids_subquery(user_id) total_duration = func.coalesce(func.sum(UserReadingSession.duration), 0) query = ( db.session.query( - related_user_ids.c.user_id.label("user_id"), User.name.label("name"), User.username.label("username"), - total_duration.label("session_duration_ms"), + total_duration.label("value"), ) + .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) .outerjoin( UserReadingSession, @@ -341,29 +321,17 @@ def exercises_done_leaderboard( from zeeguu.core.model.exercise import Exercise from zeeguu.core.model.user_word import UserWord - friend_user_ids = ( - db.session.query( - case( - (Friend.user_id == user_id, Friend.friend_id), - else_=Friend.user_id, - ).label("user_id") - ) - .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - ) - - related_user_ids = friend_user_ids.union( - db.session.query(literal(user_id).label("user_id")) - ).subquery() + related_user_ids = Friend._related_user_ids_subquery(user_id) exercises_done_count = func.count(Exercise.id) query = ( db.session.query( - related_user_ids.c.user_id.label("user_id"), User.name.label("name"), User.username.label("username"), - exercises_done_count.label("exercises_done_count"), + exercises_done_count.label("value"), ) + .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) .outerjoin(UserWord, UserWord.user_id == related_user_ids.c.user_id) .outerjoin( @@ -459,6 +427,8 @@ def search_users(current_user_id: int, term: str, limit: int = 20): query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) results = [] + + # TODO this is N+1 for user in query.all(): # Friendship status friendship = Friend.query.filter( From 55e9bc54f85ead0f697d663664933c6892a70ae4 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 22 Mar 2026 16:57:56 +0100 Subject: [PATCH 08/43] Worked on friends + leaderboards --- zeeguu/api/endpoints/friends.py | 9 ++++++--- zeeguu/core/model/friend.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 845b3364..98969e4b 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -314,6 +314,7 @@ def _serialize_user(user: User): warning("_serialize_user: user is None") return {} + # TODO we shouldn't provide this much sensitive info about the friends 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}") @@ -336,12 +337,14 @@ def _serialize_users(users: list[User]): def _serialize_leaderboard_row(row): - name = getattr(row, "name", None) or row[0] - username = getattr(row, "username", None) or row[1] - value = getattr(row, "value", None) or row[2] + user_id = getattr(row, "user_id", None) or row[0] + name = getattr(row, "name", None) or row[1] + username = getattr(row, "username", None) or row[2] + value = getattr(row, "value", None) or row[3] return { "user": { + "id": user_id, "name": name, "username": username, }, diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 5e027fcf..0b4cd016 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,5 +1,7 @@ from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_, and_, literal, case from sqlalchemy.orm import relationship, object_session + +from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model.db import db from zeeguu.core.model.user import User from datetime import datetime, timedelta @@ -187,6 +189,7 @@ def exercise_leaderboard( query = ( db.session.query( + User.id.label("user_id"), User.name.label("name"), User.username.label("username"), total_duration.label("value"), @@ -233,6 +236,7 @@ def read_articles_leaderboard( query = ( db.session.query( + User.id.label("user_id"), User.name.label("name"), User.username.label("username"), completed_articles_count.label("value"), @@ -280,6 +284,7 @@ def reading_sessions_leaderboard( query = ( db.session.query( + User.id.label("user_id"), User.name.label("name"), User.username.label("username"), total_duration.label("value"), @@ -327,6 +332,7 @@ def exercises_done_leaderboard( query = ( db.session.query( + User.id.label("user_id"), User.name.label("name"), User.username.label("username"), exercises_done_count.label("value"), @@ -442,20 +448,29 @@ def search_users(current_user_id: int, term: str, limit: int = 20): ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) ).order_by(FriendRequest.created_at.desc()).first() + user_avatar = UserAvatar.find(user.id) + 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, - }, - }) + user_data = { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + "friendship": friendship_or_friend_request, + } + + if user_avatar: + user_data["user_avatar"] = { + "image_name": user_avatar.image_name, + "character_color": user_avatar.character_color, + "background_color": user_avatar.background_color, + } + + results.append({"user": user_data}) return results @staticmethod From c1a3a4ee3943ee30ec7254f03ab4d49b58fdce3d Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 22 Mar 2026 17:10:25 +0100 Subject: [PATCH 09/43] Fixed minor issue in user search --- zeeguu/core/model/friend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 0b4cd016..e2617268 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -421,6 +421,7 @@ def search_users(current_user_id: int, term: str, limit: int = 20): # Build base query filters = [] + term = term.lower() 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 From 5e7ba4329179f7b3cf8b27b9477b5cbda08c38f7 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Mon, 23 Mar 2026 18:42:36 +0100 Subject: [PATCH 10/43] Added listening time leaderboard --- zeeguu/api/endpoints/friends.py | 5 ++-- zeeguu/core/model/friend.py | 51 +++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 98969e4b..44823598 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -13,10 +13,11 @@ from . import api LEADERBOARD_METRICS = { - "exercise_time": Friend.exercise_leaderboard, + "exercise_time": Friend.exercise_time_leaderboard, "exercises_done": Friend.exercises_done_leaderboard, "articles_read": Friend.read_articles_leaderboard, - "reading_sessions": Friend.reading_sessions_leaderboard, + "reading_time": Friend.reading_time_leaderboard, + "listening_time": Friend.listening_time_leaderboard, } diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index e2617268..f2998609 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -171,7 +171,7 @@ def _related_user_ids_subquery(user_id: int): ) @staticmethod - def exercise_leaderboard( + def exercise_time_leaderboard( user_id: int, limit: int = 20, from_date=None, @@ -217,6 +217,53 @@ def exercise_leaderboard( return query.all() + @staticmethod + def listening_time_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, + ): + """ + Return leaderboard rows for current user and all friends ordered by total + listening session duration in descending order. + """ + from zeeguu.core.model.user_listening_session import UserListeningSession + + related_user_ids = Friend._related_user_ids_subquery(user_id) + + total_duration = func.coalesce(func.sum(UserListeningSession.duration), 0) + + query = ( + db.session.query( + User.id.label("user_id"), + User.name.label("name"), + User.username.label("username"), + total_duration.label("value"), + ) + .select_from(related_user_ids) + .join(User, User.id == related_user_ids.c.user_id) + .outerjoin( + UserListeningSession, + and_( + UserListeningSession.user_id == related_user_ids.c.user_id, + UserListeningSession.start_time >= from_date + if from_date is not None + else True, + UserListeningSession.start_time <= to_date + if to_date is not None + else True, + ), + ) + .group_by(related_user_ids.c.user_id, User.name, User.username) + .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + ) + + if limit is not None: + query = query.limit(limit) + + return query.all() + @staticmethod def read_articles_leaderboard( user_id: int, @@ -266,7 +313,7 @@ def read_articles_leaderboard( return query.all() @staticmethod - def reading_sessions_leaderboard( + def reading_time_leaderboard( user_id: int, limit: int = 20, from_date=None, From 8f15446e7b20cc8d9ea2347090c5ef9853414f7a Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Wed, 25 Mar 2026 00:02:31 +0100 Subject: [PATCH 11/43] Fixed some previous merging issues, and added fetching UserAvatars for /get_friend_requests --- zeeguu/api/endpoints/friends.py | 26 +++++++++++++++++++++----- zeeguu/api/endpoints/user.py | 26 ++------------------------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 44823598..8359b9a9 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,5 +1,6 @@ -import flask from datetime import datetime + +import flask from flask import request from sqlalchemy.orm.exc import NoResultFound @@ -9,6 +10,7 @@ from zeeguu.core.model import User from zeeguu.core.model.friend import Friend from zeeguu.core.model.friend_request import FriendRequest +from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.logging import log, warning from . import api @@ -98,8 +100,22 @@ 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] + friend_requests = FriendRequest.get_friend_requests_for_user(flask.g.user_id) + result = [] + for req in friend_requests: + serialized_req = _serialize_friend_request(req) + user_avatar = UserAvatar.find(serialized_req["sender"]["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 + ) + serialized_req["sender"]["user_avatar"] = user_avatar_dict + result.append(serialized_req) return json_result(result) @@ -113,8 +129,8 @@ 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] + friend_requests = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friend_requests] return json_result(result) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index da9f1ef2..e33dd734 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -244,35 +244,13 @@ def user_settings(): 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_avatar_background_color) submitted_password = data.get("password", None) if submitted_password: user.update_password(submitted_password) + zeeguu.core.model.db.session.add(user_avatar) zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" From 9215034c43a6e076d9cbea2f39010d79c42372e3 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 28 Mar 2026 12:41:29 +0100 Subject: [PATCH 12/43] Fixed username charset and collate --- tools/migrations/26-02-24-a-add_username.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/migrations/26-02-24-a-add_username.sql b/tools/migrations/26-02-24-a-add_username.sql index 557f061d..08196ca4 100644 --- a/tools/migrations/26-02-24-a-add_username.sql +++ b/tools/migrations/26-02-24-a-add_username.sql @@ -1,6 +1,9 @@ ALTER TABLE user ADD COLUMN username VARCHAR(50); +ALTER TABLE user +MODIFY username VARCHAR(50) CHARACTER SET utf8 COLLATE utf8_bin; + -- This is maybe needed SET SQL_SAFE_UPDATES = 0; @@ -15,7 +18,7 @@ SET SQL_SAFE_UPDATES = 1; -- Change the column to be not null and unique ALTER TABLE user -MODIFY username VARCHAR(50) NOT NULL; +MODIFY username VARCHAR(50) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL; ALTER TABLE user ADD CONSTRAINT unique_username UNIQUE (username); \ No newline at end of file From 6df31a696de8e7cceb536de350eb6c33f7a10c21 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 28 Mar 2026 13:22:11 +0100 Subject: [PATCH 13/43] Fixed bugs with /search_users --- zeeguu/api/endpoints/friends.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 8359b9a9..2c5b9ca2 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -262,20 +262,21 @@ def unfriend(): # --------------------------------------------------------------------------- -@api.route("/search_users/", methods=["GET"]) +@api.route("/search_users", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def search_by_username(username): +def search_by_search_term(): """ - Search for users with for the current user + Search for users with matching the search term 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") + search_term = flask.request.args.get("query") + if not search_term or search_term.strip() == "": + return json_result([]) - 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") + search_term = search_term.strip() + result = Friend.search_users(flask.g.user_id, search_term) + log(f"search_users: user_id={flask.g.user_id} searched for search_term='{search_term}' and found {len(result)} results") return json_result(result) From d0013d3ba60596fd077cd328ab2405f9c44af61f Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 28 Mar 2026 16:53:59 +0100 Subject: [PATCH 14/43] Started simplifying/refactoring friend functionalities --- zeeguu/api/endpoints/friends.py | 195 +++++++++++++------------- zeeguu/core/model/friend.py | 210 ++++++++++++++++------------ zeeguu/core/model/friend_request.py | 27 ++-- 3 files changed, 227 insertions(+), 205 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 2c5b9ca2..69f5358a 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -4,6 +4,7 @@ from flask import request from sqlalchemy.orm.exc import NoResultFound +from zeeguu.core.model import UserLanguage 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 @@ -31,7 +32,7 @@ @requires_session def get_friends(user_id: int = None): """ - Get all friends for the current user, or for a friend by user id. + 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 @@ -39,29 +40,15 @@ def get_friends(user_id: int = None): 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) + friend_details = Friend.get_friends_with_details(used_user_id) result = [ - _serialize_user_with_friendship(entry["user"], entry["friendship"]) - for entry in friends_with_friendships + _serialize_user_with_friendship_details(friend_detail) + for friend_detail in friend_details ] 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("/friends_leaderboard", methods=["GET"]) @cross_domain @requires_session @@ -91,45 +78,33 @@ def friends_leaderboard(): # --------------------------------------------------------------------------- -@api.route("/get_friend_requests", methods=["GET"]) +@api.route("/get_received_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_friend_requests(): +def get_received_friend_requests(): """ - Get all friend requests of a user + Get all friend requests received by a user. """ - - friend_requests = FriendRequest.get_friend_requests_for_user(flask.g.user_id) + friend_requests = FriendRequest.get_received_friend_requests_for_user(flask.g.user_id) result = [] for req in friend_requests: - serialized_req = _serialize_friend_request(req) - user_avatar = UserAvatar.find(serialized_req["sender"]["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 - ) - serialized_req["sender"]["user_avatar"] = user_avatar_dict + serialized_req = _serialize_friend_request(req[0]) + serialized_req["sender"]["avatar"] = _serialize_user_avatar(req[1]) result.append(serialized_req) return json_result(result) # --------------------------------------------------------------------------- -@api.route("/get_pending_friend_requests", methods=["GET"]) +@api.route("/get_sent_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_pending_friend_requests(): +def get_sent_friend_requests(): """ - Get all pending friend requests of a user + Get all friend requests sent by a user. """ - - friend_requests = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) + friend_requests = FriendRequest.get_sent_friend_requests_for_user(flask.g.user_id) result = [_serialize_friend_request(req) for req in friend_requests] return json_result(result) @@ -141,12 +116,12 @@ def get_pending_friend_requests(): @requires_session def send_friend_request(): """ - Send a friend request from sender (current user with flask.g.user_id) to receiver + Send a friend request from sender (currently logged-in user) 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) + status_code, error_message = _validate_friend_request_participants(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) @@ -175,11 +150,6 @@ def delete_friend_request(): 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}) @@ -196,8 +166,8 @@ def accept_friend_request(): # 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) + + status_code, error = _validate_friend_request_participants(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) @@ -223,7 +193,8 @@ def reject_friend_request(): # 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) + + status_code, error_message = _validate_friend_request_participants(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}") @@ -240,17 +211,16 @@ def reject_friend_request(): @requires_session def unfriend(): """ - Unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database + Unfriend two users by deleting 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) + status_code, error_message = _validate_friend_request_participants(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}) @@ -268,14 +238,19 @@ def unfriend(): @requires_session def search_by_search_term(): """ - Search for users with matching the search term for the current user + Search for users matching the search term. """ search_term = flask.request.args.get("query") if not search_term or search_term.strip() == "": return json_result([]) search_term = search_term.strip() - result = Friend.search_users(flask.g.user_id, search_term) + user_details = Friend.search_users(flask.g.user_id, search_term) + result = [ + _serialize_user_with_friendship_details(user_detail) + for user_detail in user_details + ] + log(f"search_users: user_id={flask.g.user_id} searched for search_term='{search_term}' and found {len(result)} results") return json_result(result) @@ -284,40 +259,28 @@ def search_by_search_term(): # 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 +def _serialize_user_with_friendship_details(user_data): + result = _serialize_user(user_data.get("user")) + result["friendship"] = _serialize_friendship(user_data.get("friendship")) + result["friend_request"] = _serialize_friend_request(user_data.get("friend_request")) + result["avatar"] = _serialize_user_avatar(user_data.get("user_avatar")) + result["languages"] = _serialize_user_languages(user_data.get("user_languages")) + return result - Returns: - dict: JSON-serializable dictionary - """ + +def _serialize_user(user: User): 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, + "id": user.id, + "name": user.name, + "username": user.username, } def _serialize_friendship(friendship: Friend, status: str = "accepted"): + if friendship is None: + return None + return { - "id": friendship.id, "sender_id": friendship.user_id, "receiver_id": friendship.friend_id, "created_at": friendship.created_at, @@ -327,31 +290,56 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): } -def _serialize_user(user: User): - if user is None: - warning("_serialize_user: user is None") - return {} +def _serialize_user_avatar(user_avatar: UserAvatar): + if user_avatar is None: + return None - # TODO we shouldn't provide this much sensitive info about the friends - 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 = {} + return { + "image_name": user_avatar.image_name, + "character_color": user_avatar.character_color, + "background_color": user_avatar.background_color, + } - result["id"] = user.id - return result +def _serialize_user_languages(user_languages: list[UserLanguage]): + if not user_languages: + return None + return [{ + "language": user_language.language.as_dictionary(), + "daily_streak": user_language.daily_streak, + "max_streak": user_language.max_streak, + } for user_language in user_languages] -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_friend_request(friend_request: FriendRequest): + """ + Serialize a FriendRequest object into JSON-friendly dict. -def _serialize_users(users: list[User]): - return [_serialize_user(user) for user in users] + Args: + friend_request (FriendRequest): The friend request object + + Returns: + dict: JSON-serializable dictionary + """ + if not friend_request: + return None + + return { + "sender": { + "id": friend_request.sender.id, # This is the user_id is that necessary? + "name": friend_request.sender.name, + "username": friend_request.sender.username, + }, + "receiver": { + "id": friend_request.receiver.id, # This is the user_id is that necessary? + "name": friend_request.receiver.name, + "username": friend_request.receiver.username, + }, + "friend_request_status": friend_request.status, + "created_at": friend_request.created_at.isoformat() if friend_request.created_at else None, + "responded_at": friend_request.responded_at.isoformat() if friend_request.responded_at else None, + } def _serialize_leaderboard_row(row): @@ -365,12 +353,17 @@ def _serialize_leaderboard_row(row): "id": user_id, "name": name, "username": username, + "user_avatar": { + "image_name": getattr(row, "image_name", None), + "character_color": getattr(row, "character_color", None), + "background_color": getattr(row, "background_color", None), + } }, "value": value, } -def _is_friend_request_valid(sender_id, receiver_id) -> tuple[int, str]: +def _validate_friend_request_participants(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 diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index f2998609..ff9dda18 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -98,44 +98,49 @@ def get_friends(user_id): 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. + def get_friends_with_details(user_id: int): """ - 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: + Return a list of all friends of the given user_id along with related data. + + Returns a list of dictionaries, each representing a friendship containing: + - "user": the User object representing the friend + - "friendship": the Friend object linking the two users + - "avatar": the UserAvatar object for the friend (or None if not set) + - "languages": the list of active language of the friend (a list of + UserLanguage objects) + """ + from zeeguu.core.model import UserLanguage + rows = ( + db.session.query(User, Friend, UserAvatar, UserLanguage) + .select_from(User) + .join( + Friend, + or_( + and_(Friend.user_id == user_id, Friend.friend_id == User.id), + and_(Friend.friend_id == user_id, Friend.user_id == User.id), + ) + ) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) + .outerjoin(UserLanguage, UserLanguage.user_id == User.id) + .all() + ) - if friendship.user_id == user_id: - other_user_id = friendship.friend_id - else: - other_user_id = friendship.user_id + grouped = {} + for user, friendship, avatar, language in rows: + key = friendship.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 + if key not in grouped: + grouped[key] = { + "user": user, + "friendship": friendship, + "user_avatar": avatar, + "user_languages": [] + } - friend_user = users_by_id.get(other_user_id) - if not friend_user: - continue - result.append({"user": friend_user, "friendship": friendship}) + if language: + grouped[key]["user_languages"].append(language) - return result + return list(grouped.values()) @classmethod def are_friends(cls, user1_id: int, user2_id: int) -> bool: @@ -192,14 +197,18 @@ def exercise_time_leaderboard( User.id.label("user_id"), User.name.label("name"), User.username.label("username"), + UserAvatar.image_name.label("image_name"), + UserAvatar.character_color.label("character_color"), + UserAvatar.background_color.label("background_color"), total_duration.label("value"), ) .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) .outerjoin( UserExerciseSession, and_( - UserExerciseSession.user_id == related_user_ids.c.user_id, + UserExerciseSession.user_id == User.id, UserExerciseSession.start_time >= from_date if from_date is not None else True, @@ -208,8 +217,8 @@ def exercise_time_leaderboard( else True, ), ) - .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + .group_by(User.id, User.name, User.username) + .order_by(total_duration.desc(), User.id.asc()) ) if limit is not None: @@ -239,14 +248,18 @@ def listening_time_leaderboard( User.id.label("user_id"), User.name.label("name"), User.username.label("username"), + UserAvatar.image_name.label("image_name"), + UserAvatar.character_color.label("character_color"), + UserAvatar.background_color.label("background_color"), total_duration.label("value"), ) .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) .outerjoin( UserListeningSession, and_( - UserListeningSession.user_id == related_user_ids.c.user_id, + UserListeningSession.user_id == User.id, UserListeningSession.start_time >= from_date if from_date is not None else True, @@ -255,8 +268,8 @@ def listening_time_leaderboard( else True, ), ) - .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + .group_by(User.id, User.name, User.username) + .order_by(total_duration.desc(), User.id.asc()) ) if limit is not None: @@ -286,14 +299,18 @@ def read_articles_leaderboard( User.id.label("user_id"), User.name.label("name"), User.username.label("username"), + UserAvatar.image_name.label("image_name"), + UserAvatar.character_color.label("character_color"), + UserAvatar.background_color.label("background_color"), completed_articles_count.label("value"), ) .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) .outerjoin( UserArticle, and_( - UserArticle.user_id == related_user_ids.c.user_id, + UserArticle.user_id == User.id, UserArticle.completed_at.isnot(None), UserArticle.completed_at >= from_date if from_date is not None @@ -303,8 +320,8 @@ def read_articles_leaderboard( else True, ), ) - .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(completed_articles_count.desc(), related_user_ids.c.user_id.asc()) + .group_by(User.id, User.name, User.username) + .order_by(completed_articles_count.desc(), User.id.asc()) ) if limit is not None: @@ -334,14 +351,18 @@ def reading_time_leaderboard( User.id.label("user_id"), User.name.label("name"), User.username.label("username"), + UserAvatar.image_name.label("image_name"), + UserAvatar.character_color.label("character_color"), + UserAvatar.background_color.label("background_color"), total_duration.label("value"), ) .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) .outerjoin( UserReadingSession, and_( - UserReadingSession.user_id == related_user_ids.c.user_id, + UserReadingSession.user_id == User.id, UserReadingSession.start_time >= from_date if from_date is not None else True, @@ -350,8 +371,8 @@ def reading_time_leaderboard( else True, ), ) - .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(total_duration.desc(), related_user_ids.c.user_id.asc()) + .group_by(User.id, User.name, User.username) + .order_by(total_duration.desc(), User.id.asc()) ) if limit is not None: @@ -382,11 +403,15 @@ def exercises_done_leaderboard( User.id.label("user_id"), User.name.label("name"), User.username.label("username"), + UserAvatar.image_name.label("image_name"), + UserAvatar.character_color.label("character_color"), + UserAvatar.background_color.label("background_color"), exercises_done_count.label("value"), ) .select_from(related_user_ids) .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserWord, UserWord.user_id == related_user_ids.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) + .outerjoin(UserWord, UserWord.user_id == User.id) .outerjoin( Exercise, and_( @@ -395,8 +420,8 @@ def exercises_done_leaderboard( Exercise.time <= to_date if to_date is not None else True, ), ) - .group_by(related_user_ids.c.user_id, User.name, User.username) - .order_by(exercises_done_count.desc(), related_user_ids.c.user_id.asc()) + .group_by(User.id, User.name, User.username) + .order_by(exercises_done_count.desc(), User.id.asc()) ) if limit is not None: @@ -455,70 +480,69 @@ def find_friend_details(cls, user_id: int, friend_user_id: int): return details - @staticmethod - def search_users(current_user_id: int, term: str, limit: int = 20): + @classmethod + def search_users(cls, current_user_id: int, term: str, limit: int = 20): """ - Search users by username (partial match) or exact email. + Search users by username (partial match) or exact email or name. For each user, return: - user info - friend request status (if any) - friendship status (if any) """ + from sqlalchemy import or_, func from zeeguu.core.model.friend_request import FriendRequest - - # Build base query + filters = [] term = term.lower() if term: - filters.append(func.lower(User.username).ilike(f"%{term}%")) # ilike for case-insensitive partial match + filters.append(func.lower(User.username).ilike(f"%{term}%")) # case-insensitive partial match for username 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 = [] + query = ( + db.session.query(User, UserAvatar) + .select_from(User) + .filter( + or_(*filters), + User.id != current_user_id + ) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) + .limit(limit) + ) - # TODO this is N+1 - 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() - - user_avatar = UserAvatar.find(user.id) - - - friendship_or_friend_request = Friend._get_friendship_or_friendrequest( - friendship, - friend_request) - - user_data = { - "id": user.id, - "name": user.name, - "username": user.username, - "email": user.email, - "friendship": friendship_or_friend_request, - } + # Fetch all friendships involving the current user + friendships = Friend.query.filter( + (Friend.user_id == current_user_id) | (Friend.friend_id == current_user_id) + ).all() - if user_avatar: - user_data["user_avatar"] = { - "image_name": user_avatar.image_name, - "character_color": user_avatar.character_color, - "background_color": user_avatar.background_color, - } + friendship_map = {} + for friendship in friendships: + other_id = friendship.friend_id if friendship.user_id == current_user_id else friendship.user_id + friendship_map[other_id] = friendship + + # Fetch all friend requests involving the current user + friend_requests = FriendRequest.query.filter( + (FriendRequest.sender_id == current_user_id) | (FriendRequest.receiver_id == current_user_id) + ).all() + + friend_request_map = {} + for friend_request in friend_requests: + other_id = friend_request.receiver_id if friend_request.sender_id == current_user_id else friend_request.sender_id + friend_request_map[other_id] = friend_request + + results = [] + + for user, avatar in query.all(): + results.append({ + "user": user, + "user_avatar": avatar, + "friendship": friendship_map.get(user.id), + "friend_request": friend_request_map.get(user.id), + }) - results.append({"user": user_data}) return results @staticmethod diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 8089da40..d035bc89 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -1,5 +1,7 @@ from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func from sqlalchemy.orm import relationship + +from zeeguu.core.model.user_avatar import UserAvatar 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 @@ -13,7 +15,7 @@ class FriendRequest(db.Model): 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"), + Enum("pending", "accepted", "rejected", name="friend_request_status"), # TODO Do we need these? We basically only use pending since the request is deleted in both other cases default="pending", ) created_at = Column(DateTime, default=func.now()) @@ -92,35 +94,38 @@ def send_friend_request(cls, sender_id: int, receiver_id: int): return new_request @classmethod - def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): + def get_received_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 + List[Tuple[FriendRequest, UserAvatar]]: + A list of tuples, each containing: + - FriendRequest: the friend request object + - UserAvatar: the avatar of the sender (or None if not set) """ requests = ( - db.session.query(cls) + db.session.query(cls, UserAvatar) .filter(FriendRequest.receiver_id == user_id) .filter(FriendRequest.status == status) + .outerjoin(UserAvatar, UserAvatar.user_id == cls.sender_id) .order_by(FriendRequest.created_at.desc()) .all() ) return requests @classmethod - def get_pending_friend_requests_for_user(cls, user_id: int): + def get_sent_friend_requests_for_user(cls, user_id: int, status: str = "pending"): """ - Get pending friend requests received by a user. + Get friend requests sent by a user. Args: - cls (FriendRequest): The FriendRequest class user_id (int): ID of the user + status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" Returns: List[FriendRequest]: List of pending friend request objects @@ -128,7 +133,7 @@ def get_pending_friend_requests_for_user(cls, user_id: int): requests = ( db.session.query(cls) .filter(FriendRequest.sender_id == user_id) - .filter(FriendRequest.status == "pending") + .filter(FriendRequest.status == status) .order_by(FriendRequest.created_at.desc()) .all() ) @@ -138,12 +143,12 @@ def get_pending_friend_requests_for_user(cls, user_id: int): 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( + friend_request = 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.delete(friend_request) db.session.commit() return True except NoResultFound: From 775d5d226866d757990bae637069a1c9d1057abf Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 29 Mar 2026 10:03:16 +0200 Subject: [PATCH 15/43] Continued simplifying/refactoring friend functionalities --- .../migrations/26-02-24-friendship_system.sql | 4 +- zeeguu/api/endpoints/friends.py | 2 +- zeeguu/core/model/friend.py | 94 ++++++++----------- 3 files changed, 44 insertions(+), 56 deletions(-) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index 1952498d..101596cb 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -1,5 +1,5 @@ --- friends table -CREATE TABLE friends ( +-- friend table +CREATE TABLE friend ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, friend_id INT NOT NULL, diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 69f5358a..2ca1660d 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -211,7 +211,7 @@ def reject_friend_request(): @requires_session def unfriend(): """ - Unfriend two users by deleting the Friends row (friendship record) in the database. + Unfriend two users by deleting the Friend row (friendship record) in the database. """ sender_id = flask.g.user_id receiver_id = request.json.get("receiver_id") diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index ff9dda18..19352881 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -6,18 +6,20 @@ from zeeguu.core.model.user import User from datetime import datetime, timedelta + class Friend(db.Model): - __tablename__ = "friends" + __tablename__ = "friend" __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) + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + user = relationship(User) + friend_id = Column(Integer, ForeignKey(User.id), nullable=False) + friend = relationship(User) 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. @@ -70,18 +72,6 @@ def update_friend_streak(self, session=None, commit=True): 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.""" @@ -90,8 +80,8 @@ def get_friends(user_id): 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) + (Friend.user_id == user_id) & (User.id == Friend.friend_id) + | (Friend.friend_id == user_id) & (User.id == Friend.user_id) ) .all() ) @@ -157,7 +147,6 @@ def are_friends(cls, user1_id: int, user2_id: int) -> bool: ).first() return friendship is not None - @staticmethod def _related_user_ids_subquery(user_id: int): # For each friendship row touching this user, select "the other user". @@ -177,10 +166,10 @@ def _related_user_ids_subquery(user_id: int): @staticmethod def exercise_time_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, ): """ Return leaderboard rows for current user and all friends ordered by total @@ -279,10 +268,10 @@ def listening_time_leaderboard( @staticmethod def read_articles_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, ): """ Return leaderboard rows for current user and all friends ordered by @@ -331,10 +320,10 @@ def read_articles_leaderboard( @staticmethod def reading_time_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, ): """ Return leaderboard rows for current user and all friends ordered by total @@ -382,10 +371,10 @@ def reading_time_leaderboard( @staticmethod def exercises_done_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, ): """ Return leaderboard rows for current user and all friends ordered by @@ -430,7 +419,7 @@ def exercises_done_leaderboard( return query.all() @classmethod - def remove_friendship(cls, user1_id: int, user2_id: int)->bool: + 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)) | @@ -441,7 +430,7 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: db.session.delete(friendship) db.session.commit() return True - + return False @classmethod @@ -471,7 +460,7 @@ def find_friend_details(cls, user_id: int, friend_user_id: int): ).order_by(FriendRequest.created_at.desc()).first() details = friend.details_as_dictionary() - details["friendship"] = cls._get_friendship_or_friendrequest(friendship, friend_request) + details["friendship"] = cls._get_friendship_or_friend_request(friendship, friend_request) if friendship: details["friends_since"] = friendship.created_at.isoformat() if friendship.created_at else None @@ -479,7 +468,6 @@ def find_friend_details(cls, user_id: int, friend_user_id: int): return details - @classmethod def search_users(cls, current_user_id: int, term: str, limit: int = 20): """ @@ -495,10 +483,10 @@ def search_users(cls, current_user_id: int, term: str, limit: int = 20): filters = [] term = term.lower() if term: - filters.append(func.lower(User.username).ilike(f"%{term}%")) # case-insensitive partial match for username - filters.append(func.lower(User.email) == term) # exact match for email - filters.append(func.lower(User.name) == term) # exact match for name - + filters.append(func.lower(User.username).ilike(f"%{term}%")) # case-insensitive partial match for username + 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 @@ -537,16 +525,16 @@ def search_users(cls, current_user_id: int, term: str, limit: int = 20): for user, avatar in query.all(): results.append({ - "user": user, - "user_avatar": avatar, - "friendship": friendship_map.get(user.id), - "friend_request": friend_request_map.get(user.id), - }) + "user": user, + "user_avatar": avatar, + "friendship": friendship_map.get(user.id), + "friend_request": friend_request_map.get(user.id), + }) return results @staticmethod - def _get_friendship_or_friendrequest(friendship, friend_request): + def _get_friendship_or_friend_request(friendship, friend_request): if friendship: return { @@ -563,8 +551,8 @@ def _get_friendship_or_friendrequest(friendship, friend_request): 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 + "sender_id": friend_request.sender_id, # TODO: Are these necessary + "receiver_id": friend_request.receiver_id, # TODO: are these necessary "friend_streak": 0, "friend_streak_last_updated": None, "friend_request_status": friend_request.status, @@ -575,10 +563,10 @@ def _get_friendship_or_friendrequest(friendship, friend_request): ), } + @staticmethod 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( @@ -594,4 +582,4 @@ def add_friendship(user_id: int, friend_id: int): db.session.add(friendship) db.session.commit() db.session.refresh(friendship) - return friendship \ No newline at end of file + return friendship From 82c2aaed887095acd1c61b25d64fc7f39b940b2f Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 29 Mar 2026 13:56:03 +0200 Subject: [PATCH 16/43] Continued simplifying/refactoring friend functionalities --- zeeguu/api/endpoints/__init__.py | 1 + zeeguu/api/endpoints/friends.py | 115 +-------- zeeguu/api/endpoints/leaderboards.py | 123 ++++++++++ zeeguu/core/leaderboards/__init__.py | 0 zeeguu/core/leaderboards/leaderboards.py | 259 ++++++++++++++++++++ zeeguu/core/model/exercise_outcome.py | 11 +- zeeguu/core/model/friend.py | 295 ++--------------------- zeeguu/core/model/user_word.py | 2 +- 8 files changed, 408 insertions(+), 398 deletions(-) create mode 100644 zeeguu/api/endpoints/leaderboards.py create mode 100644 zeeguu/core/leaderboards/__init__.py create mode 100644 zeeguu/core/leaderboards/leaderboards.py diff --git a/zeeguu/api/endpoints/__init__.py b/zeeguu/api/endpoints/__init__.py index 67ed17ec..318b16ff 100644 --- a/zeeguu/api/endpoints/__init__.py +++ b/zeeguu/api/endpoints/__init__.py @@ -50,3 +50,4 @@ from . import daily_streak from . import badges from . import friends +from . import leaderboards diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 2ca1660d..fc600777 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,28 +1,18 @@ -from datetime import datetime - import flask from flask import request from sqlalchemy.orm.exc import NoResultFound -from zeeguu.core.model import UserLanguage 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 import UserLanguage from zeeguu.core.model.friend import Friend from zeeguu.core.model.friend_request import FriendRequest from zeeguu.core.model.user_avatar import UserAvatar -from zeeguu.logging import log, warning +from zeeguu.logging import log from . import api -LEADERBOARD_METRICS = { - "exercise_time": Friend.exercise_time_leaderboard, - "exercises_done": Friend.exercises_done_leaderboard, - "articles_read": Friend.read_articles_leaderboard, - "reading_time": Friend.reading_time_leaderboard, - "listening_time": Friend.listening_time_leaderboard, -} - # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) @@ -49,34 +39,6 @@ def get_friends(user_id: int = None): return json_result(result) -@api.route("/friends_leaderboard", methods=["GET"]) -@cross_domain -@requires_session -def friends_leaderboard(): - params, error_response = _parse_leaderboard_query_params() - if error_response: - return error_response - - metric = LEADERBOARD_METRICS.get(request.args.get("metric")) - - if not metric: - return make_error(400, "Invalid leaderboard metric") - - rows = metric( - flask.g.user_id, - limit=params["limit"], - from_date=params["from_date"], - to_date=params["to_date"], - ) - - result = [ - _serialize_leaderboard_row(row) - for row in rows - ] - - return json_result(result) - - # --------------------------------------------------------------------------- @api.route("/get_received_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @@ -342,27 +304,6 @@ def _serialize_friend_request(friend_request: FriendRequest): } -def _serialize_leaderboard_row(row): - user_id = getattr(row, "user_id", None) or row[0] - name = getattr(row, "name", None) or row[1] - username = getattr(row, "username", None) or row[2] - value = getattr(row, "value", None) or row[3] - - return { - "user": { - "id": user_id, - "name": name, - "username": username, - "user_avatar": { - "image_name": getattr(row, "image_name", None), - "character_color": getattr(row, "character_color", None), - "background_color": getattr(row, "background_color", None), - } - }, - "value": value, - } - - def _validate_friend_request_participants(sender_id, receiver_id) -> tuple[int, str]: """ :param sender_id: the user_id of the sender of the friend request @@ -378,55 +319,3 @@ def _validate_friend_request_participants(sender_id, receiver_id) -> tuple[int, return 422, "cannot send friend request to yourself" return 200, "ok" - - -def _parse_leaderboard_query_params(): - """ - Parse common leaderboard query params: - limit: positive integer - from_date: ISO datetime string (optional) - to_date: ISO datetime string (optional) - """ - limit = 20 - limit_arg = request.args.get("limit") - if limit_arg is not None: - try: - limit = int(limit_arg) - except ValueError: - return None, make_error(400, "limit must be an integer") - - if limit <= 0: - return None, make_error(400, "limit must be greater than 0") - - from_date_str = request.args.get("from_date") - to_date_str = request.args.get("to_date") - - from_date = None - to_date = None - - if from_date_str: - from_date, error = _parse_iso_datetime(from_date_str, "from_date") - if error: - return None, error - - if to_date_str: - to_date, error = _parse_iso_datetime(to_date_str, "to_date") - if error: - return None, error - - if from_date and to_date and from_date > to_date: - return None, make_error(400, "from_date must be before or equal to to_date") - - return { - "limit": limit, - "from_date": from_date, - "to_date": to_date, - }, None - - -def _parse_iso_datetime(value: str, param_name: str): - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) - return parsed, None - except ValueError: - return None, make_error(400, f"{param_name} must be a valid ISO datetime") diff --git a/zeeguu/api/endpoints/leaderboards.py b/zeeguu/api/endpoints/leaderboards.py new file mode 100644 index 00000000..76b05b0e --- /dev/null +++ b/zeeguu/api/endpoints/leaderboards.py @@ -0,0 +1,123 @@ +from datetime import datetime +from typing import Callable, Optional + +import flask +from flask import request + +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.leaderboards.leaderboards import exercise_time_leaderboard, exercises_done_leaderboard, \ + read_articles_leaderboard, reading_time_leaderboard, listening_time_leaderboard +from . import api + +LeaderboardMetric = Callable[[int, int, Optional[datetime], Optional[datetime]], list] + +LEADERBOARD_METRICS: dict[str, LeaderboardMetric] = { + "exercise_time": exercise_time_leaderboard, + "exercises_done": exercises_done_leaderboard, + "read_articles": read_articles_leaderboard, + "reading_time": reading_time_leaderboard, + "listening_time": listening_time_leaderboard, +} + + +# --------------------------------------------------------------------------- +@api.route("/friends_leaderboard", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def friends_leaderboard(): + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + metric = LEADERBOARD_METRICS.get(request.args.get("metric")) + + if not metric: + return make_error(400, "Invalid leaderboard metric") + + rows = metric( + flask.g.user_id, + params["limit"], + params["from_date"], + params["to_date"], + ) + + result = [ + _serialize_leaderboard_row(row) + for row in rows + ] + + return json_result(result) + + +# --------------------------------------------------------------------------- +# Helper functions below +# --------------------------------------------------------------------------- + +def _parse_leaderboard_query_params(): + """ + Parse common leaderboard query params: + limit: positive integer + from_date: ISO datetime string (optional) + to_date: ISO datetime string (optional) + """ + limit = 20 + limit_arg = request.args.get("limit") + if limit_arg is not None: + try: + limit = int(limit_arg) + except ValueError: + return None, make_error(400, "limit must be an integer") + + if limit <= 0: + return None, make_error(400, "limit must be greater than 0") + + from_date_str = request.args.get("from_date") + to_date_str = request.args.get("to_date") + + from_date = None + to_date = None + + if from_date_str: + from_date, error = _parse_iso_datetime(from_date_str, "from_date") + if error: + return None, error + + if to_date_str: + to_date, error = _parse_iso_datetime(to_date_str, "to_date") + if error: + return None, error + + if from_date and to_date and from_date > to_date: + return None, make_error(400, "from_date must be before or equal to to_date") + + return { + "limit": limit, + "from_date": from_date, + "to_date": to_date, + }, None + + +def _parse_iso_datetime(value: str, param_name: str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + return parsed, None + except ValueError: + return None, make_error(400, f"{param_name} must be a valid ISO datetime") + + +def _serialize_leaderboard_row(row): + return { + "user": { + "name": row.name, + "username": row.username, + "avatar": { + "image_name": row.image_name, + "character_color": row.character_color, + "background_color": row.background_color, + } + }, + "value": row.value, + } diff --git a/zeeguu/core/leaderboards/__init__.py b/zeeguu/core/leaderboards/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/zeeguu/core/leaderboards/leaderboards.py b/zeeguu/core/leaderboards/leaderboards.py new file mode 100644 index 00000000..d4b498e9 --- /dev/null +++ b/zeeguu/core/leaderboards/leaderboards.py @@ -0,0 +1,259 @@ +from sqlalchemy import func, and_, case, or_, literal + +from zeeguu.core.model import User +from zeeguu.core.model.db import db +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.user_avatar import UserAvatar + + +def exercise_time_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, +): + """ + Return leaderboard rows for current user and all friends ordered by total + exercise session duration in descending order. + """ + from zeeguu.core.model.user_exercise_session import UserExerciseSession + + related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) + + total_duration = func.coalesce(func.sum(UserExerciseSession.duration), 0) + + joins = [ + ( + UserExerciseSession, + and_( + UserExerciseSession.user_id == User.id, + *([UserExerciseSession.start_time >= from_date] if from_date else []), + *([UserExerciseSession.start_time <= to_date] if to_date else []), + ) + ) + ] + + return _leaderboard_base( + related_user_ids, + total_duration, + joins, + limit, + ) + + +def listening_time_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, +): + """ + Return leaderboard rows for current user and all friends ordered by total + listening session duration in descending order. + """ + from zeeguu.core.model.user_listening_session import UserListeningSession + + related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) + + total_duration = func.coalesce(func.sum(UserListeningSession.duration), 0) + + joins = [ + ( + UserListeningSession, + and_( + UserListeningSession.user_id == User.id, + *([UserListeningSession.start_time >= from_date] if from_date else []), + *([UserListeningSession.start_time <= to_date] if to_date else []), + ) + ) + ] + + return _leaderboard_base( + related_user_ids, + total_duration, + joins, + limit, + ) + + +def read_articles_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, +): + """ + Return leaderboard rows for current user and all friends ordered by + number of completed articles in descending order. + """ + from zeeguu.core.model.user_article import UserArticle + + related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) + + completed_articles_count = func.count(UserArticle.id) + + joins = [ + ( + UserArticle, + and_( + UserArticle.user_id == User.id, + UserArticle.completed_at.isnot(None), + *([UserArticle.completed_at >= from_date] if from_date else []), + *([UserArticle.completed_at <= to_date] if to_date else []), + ) + ) + ] + + return _leaderboard_base( + related_user_ids, + completed_articles_count, + joins, + limit, + ) + + +def reading_time_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, +): + """ + Return leaderboard rows for current user and all friends ordered by total + reading session duration in descending order. + """ + from zeeguu.core.model.user_reading_session import UserReadingSession + + related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) + + total_duration = func.coalesce(func.sum(UserReadingSession.duration), 0) + + joins = [ + ( + UserReadingSession, + and_( + UserReadingSession.user_id == User.id, + *([UserReadingSession.start_time >= from_date] if from_date else []), + *([UserReadingSession.start_time <= to_date] if to_date else []), + ) + ) + ] + + return _leaderboard_base( + related_user_ids, + total_duration, + joins, + limit, + ) + + +def exercises_done_leaderboard( + user_id: int, + limit: int = 20, + from_date=None, + to_date=None, +): + """ + Return leaderboard rows for current user and all friends ordered by + number of exercises done in descending order. + """ + from zeeguu.core.model.exercise import Exercise + from zeeguu.core.model.exercise_outcome import ExerciseOutcome + from zeeguu.core.model.user_word import UserWord + + correct_exercise_outcomes = [ + ExerciseOutcome.CORRECT, + ExerciseOutcome.CORRECT_AFTER_HINT, + *ExerciseOutcome.correct_after_translation + ] + + related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) + + exercises_done_count = func.coalesce( + func.sum( + case( + ( + Exercise.outcome.has(ExerciseOutcome.outcome.in_(correct_exercise_outcomes)), 1), else_=0)), 0 + ) + + joins = [ + (UserWord, UserWord.user_id == User.id), + ( + Exercise, + and_( + Exercise.user_word_id == UserWord.id, + *([Exercise.time >= from_date] if from_date else []), + *([Exercise.time <= to_date] if to_date else []) + ) + ), + ( + ExerciseOutcome, + ExerciseOutcome.id == Exercise.outcome_id + ), + ] + + return _leaderboard_base( + related_user_ids, + exercises_done_count, + joins, + limit, + ) + + +def _friend_leaderboard_user_ids_subquery(user_id: int): + # For each friendship row touching this user, select "the other user". + return ( + db.session.query( + case( + (Friend.user_id == user_id, Friend.friend_id), + else_=Friend.user_id, + ).label("user_id") + ) + .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + .union( + db.session.query(literal(user_id).label("user_id")) + ) + .subquery() + ) + + +def _leaderboard_base( + user_ids_subquery, + value_expr, + joins, + limit +): + # Base query selecting the users and their avatars + query = ( + db.session.query( + User.id.label("user_id"), + User.name, + User.username, + UserAvatar.image_name, + UserAvatar.character_color, + UserAvatar.background_color, + value_expr.label("value"), + ) + .select_from(user_ids_subquery) + .join(User, User.id == user_ids_subquery.c.user_id) + .outerjoin(UserAvatar, UserAvatar.user_id == User.id) + ) + + # Adding other joined tables + for model, condition in joins: + query = query.outerjoin(model, condition) + + # Group by, ordering and limiting + query = query.group_by( + User.id, + User.name, + User.username, + UserAvatar.image_name, + UserAvatar.character_color, + UserAvatar.background_color, + ).order_by(value_expr.desc(), User.id.asc()) + + if limit: + query = query.limit(limit) + + return query.all() diff --git a/zeeguu/core/model/exercise_outcome.py b/zeeguu/core/model/exercise_outcome.py index 187b38bd..f272ad42 100644 --- a/zeeguu/core/model/exercise_outcome.py +++ b/zeeguu/core/model/exercise_outcome.py @@ -21,6 +21,7 @@ class ExerciseOutcome(db.Model): ASKED_FOR_HINT = "asked_for_hint" # TODO: Rename to EXERCISE_FEEDBACK OTHER_FEEDBACK = "other_feedback" + CORRECT_AFTER_HINT = "HC" correct_outcomes = [CORRECT, TOO_EASY, "Correct"] @@ -28,6 +29,8 @@ class ExerciseOutcome(db.Model): wrong_outcomes = ["W", WRONG, SHOW_SOLUTION, ASKED_FOR_HINT] + correct_after_translation = ["TC", "TTC", "TTTC"] + @classmethod def is_valid_attempt(cls, outcome: str): """ @@ -45,13 +48,9 @@ def is_valid_attempt(cls, outcome: str): def is_correct(cls, outcome: str): is_correct = outcome == ExerciseOutcome.CORRECT # allow for a few translations before hitting the correct; they work like hints - is_correct_after_translation = outcome in [ - "TC", - "TTC", - "TTTC", - ] + is_correct_after_translation = outcome in ExerciseOutcome.correct_after_translation # if it's correct after hint it should still be fine - is_correct_after_hint = outcome == "HC" + is_correct_after_hint = outcome == ExerciseOutcome.CORRECT_AFTER_HINT return is_correct or is_correct_after_translation or is_correct_after_hint def __init__(self, outcome): diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 19352881..2f47c2c2 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,10 +1,11 @@ -from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_, and_, literal, case +from datetime import datetime, timedelta + +from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_, and_ from sqlalchemy.orm import relationship, object_session -from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model.db import db from zeeguu.core.model.user import User -from datetime import datetime, timedelta +from zeeguu.core.model.user_avatar import UserAvatar class Friend(db.Model): @@ -12,14 +13,23 @@ class Friend(db.Model): __table_args__ = {"mysql_collate": "utf8_bin"} id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey(User.id), nullable=False) - user = relationship(User) - friend_id = Column(Integer, ForeignKey(User.id), nullable=False) - friend = relationship(User) + 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) + 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" + ) + def update_friend_streak(self, session=None, commit=True): """ Update friend_streak based on both users' most recent practice in any language. @@ -147,277 +157,6 @@ def are_friends(cls, user1_id: int, user2_id: int) -> bool: ).first() return friendship is not None - @staticmethod - def _related_user_ids_subquery(user_id: int): - # For each friendship row touching this user, select "the other user". - return ( - db.session.query( - case( - (Friend.user_id == user_id, Friend.friend_id), - else_=Friend.user_id, - ).label("user_id") - ) - .filter(or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - .union( - db.session.query(literal(user_id).label("user_id")) - ) - .subquery() - ) - - @staticmethod - def exercise_time_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, - ): - """ - Return leaderboard rows for current user and all friends ordered by total - exercise session duration in descending order. - """ - from zeeguu.core.model.user_exercise_session import UserExerciseSession - - related_user_ids = Friend._related_user_ids_subquery(user_id) - - total_duration = func.coalesce(func.sum(UserExerciseSession.duration), 0) - - query = ( - db.session.query( - User.id.label("user_id"), - User.name.label("name"), - User.username.label("username"), - UserAvatar.image_name.label("image_name"), - UserAvatar.character_color.label("character_color"), - UserAvatar.background_color.label("background_color"), - total_duration.label("value"), - ) - .select_from(related_user_ids) - .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserAvatar, UserAvatar.user_id == User.id) - .outerjoin( - UserExerciseSession, - and_( - UserExerciseSession.user_id == User.id, - UserExerciseSession.start_time >= from_date - if from_date is not None - else True, - UserExerciseSession.start_time <= to_date - if to_date is not None - else True, - ), - ) - .group_by(User.id, User.name, User.username) - .order_by(total_duration.desc(), User.id.asc()) - ) - - if limit is not None: - query = query.limit(limit) - - return query.all() - - @staticmethod - def listening_time_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, - ): - """ - Return leaderboard rows for current user and all friends ordered by total - listening session duration in descending order. - """ - from zeeguu.core.model.user_listening_session import UserListeningSession - - related_user_ids = Friend._related_user_ids_subquery(user_id) - - total_duration = func.coalesce(func.sum(UserListeningSession.duration), 0) - - query = ( - db.session.query( - User.id.label("user_id"), - User.name.label("name"), - User.username.label("username"), - UserAvatar.image_name.label("image_name"), - UserAvatar.character_color.label("character_color"), - UserAvatar.background_color.label("background_color"), - total_duration.label("value"), - ) - .select_from(related_user_ids) - .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserAvatar, UserAvatar.user_id == User.id) - .outerjoin( - UserListeningSession, - and_( - UserListeningSession.user_id == User.id, - UserListeningSession.start_time >= from_date - if from_date is not None - else True, - UserListeningSession.start_time <= to_date - if to_date is not None - else True, - ), - ) - .group_by(User.id, User.name, User.username) - .order_by(total_duration.desc(), User.id.asc()) - ) - - if limit is not None: - query = query.limit(limit) - - return query.all() - - @staticmethod - def read_articles_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, - ): - """ - Return leaderboard rows for current user and all friends ordered by - number of completed articles in descending order. - """ - from zeeguu.core.model.user_article import UserArticle - - related_user_ids = Friend._related_user_ids_subquery(user_id) - - completed_articles_count = func.count(UserArticle.id) - - query = ( - db.session.query( - User.id.label("user_id"), - User.name.label("name"), - User.username.label("username"), - UserAvatar.image_name.label("image_name"), - UserAvatar.character_color.label("character_color"), - UserAvatar.background_color.label("background_color"), - completed_articles_count.label("value"), - ) - .select_from(related_user_ids) - .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserAvatar, UserAvatar.user_id == User.id) - .outerjoin( - UserArticle, - and_( - UserArticle.user_id == User.id, - UserArticle.completed_at.isnot(None), - UserArticle.completed_at >= from_date - if from_date is not None - else True, - UserArticle.completed_at <= to_date - if to_date is not None - else True, - ), - ) - .group_by(User.id, User.name, User.username) - .order_by(completed_articles_count.desc(), User.id.asc()) - ) - - if limit is not None: - query = query.limit(limit) - - return query.all() - - @staticmethod - def reading_time_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, - ): - """ - Return leaderboard rows for current user and all friends ordered by total - reading session duration in descending order. - """ - from zeeguu.core.model.user_reading_session import UserReadingSession - - related_user_ids = Friend._related_user_ids_subquery(user_id) - - total_duration = func.coalesce(func.sum(UserReadingSession.duration), 0) - - query = ( - db.session.query( - User.id.label("user_id"), - User.name.label("name"), - User.username.label("username"), - UserAvatar.image_name.label("image_name"), - UserAvatar.character_color.label("character_color"), - UserAvatar.background_color.label("background_color"), - total_duration.label("value"), - ) - .select_from(related_user_ids) - .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserAvatar, UserAvatar.user_id == User.id) - .outerjoin( - UserReadingSession, - and_( - UserReadingSession.user_id == User.id, - UserReadingSession.start_time >= from_date - if from_date is not None - else True, - UserReadingSession.start_time <= to_date - if to_date is not None - else True, - ), - ) - .group_by(User.id, User.name, User.username) - .order_by(total_duration.desc(), User.id.asc()) - ) - - if limit is not None: - query = query.limit(limit) - - return query.all() - - @staticmethod - def exercises_done_leaderboard( - user_id: int, - limit: int = 20, - from_date=None, - to_date=None, - ): - """ - Return leaderboard rows for current user and all friends ordered by - number of exercises done in descending order. - """ - from zeeguu.core.model.exercise import Exercise - from zeeguu.core.model.user_word import UserWord - - related_user_ids = Friend._related_user_ids_subquery(user_id) - - exercises_done_count = func.count(Exercise.id) - - query = ( - db.session.query( - User.id.label("user_id"), - User.name.label("name"), - User.username.label("username"), - UserAvatar.image_name.label("image_name"), - UserAvatar.character_color.label("character_color"), - UserAvatar.background_color.label("background_color"), - exercises_done_count.label("value"), - ) - .select_from(related_user_ids) - .join(User, User.id == related_user_ids.c.user_id) - .outerjoin(UserAvatar, UserAvatar.user_id == User.id) - .outerjoin(UserWord, UserWord.user_id == User.id) - .outerjoin( - Exercise, - and_( - Exercise.user_word_id == UserWord.id, - Exercise.time >= from_date if from_date is not None else True, - Exercise.time <= to_date if to_date is not None else True, - ), - ) - .group_by(User.id, User.name, User.username) - .order_by(exercises_done_count.desc(), User.id.asc()) - ) - - if limit is not None: - query = query.limit(limit) - - return query.all() - @classmethod def remove_friendship(cls, user1_id: int, user2_id: int) -> bool: # Look for friendship in either direction diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index bf40e644..fee9fb45 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -392,7 +392,7 @@ def report_exercise_outcome( ) db_session.add(exercise) - if source.source != "DAILY_AUDIO_LESSON" and outcome.correct: + if source.source != "DAILY_AUDIO_LESSON" and exercise.is_correct: from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode increment_badge_progress(db_session, BadgeCode.CORRECT_EXERCISES, self.user.id) From 21fe0f23fa932c7db438dcce2652cd5a938e7b50 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 1 Apr 2026 12:50:29 +0200 Subject: [PATCH 17/43] friend_user_id -> friend_username refactor for endpoints --- zeeguu/api/endpoints/badges.py | 8 ++- zeeguu/api/endpoints/daily_streak.py | 6 +- zeeguu/api/endpoints/friends.py | 100 +++++++++++++++------------ zeeguu/api/endpoints/user.py | 8 +-- zeeguu/core/model/friend.py | 5 +- zeeguu/core/model/user.py | 4 ++ 6 files changed, 73 insertions(+), 58 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 09ba6b95..3fdda7f6 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,6 +1,7 @@ import flask from sqlalchemy.orm import joinedload +from zeeguu.core.model import User 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 @@ -27,11 +28,11 @@ def get_not_shown_user_badge_levels(): # --------------------------------------------------------------------------- @api.route("/badges", methods=["GET"]) -@api.route("/badges/", methods=["GET"]) +@api.route("/badges/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_badges_for_user(user_id: int = None): +def get_badges_for_user(username: str = 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. @@ -55,7 +56,8 @@ def get_badges_for_user(user_id: int = None): }, ... ] """ requester_id = flask.g.user_id - used_user_id = user_id if user_id is not None else requester_id + + used_user_id = User.find_by_username(username).id if username 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.") diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index c29d9763..21b668d4 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -23,12 +23,12 @@ def get_daily_streak(): @api.route("/all_daily_streak", methods=["GET"]) -@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): +def get_all_daily_streak(username: str = None): requester_user_id = flask.g.user_id - requested_user_id = user_id if user_id is not None else requester_user_id + requested_user_id = User.find_by_username(username).id if username is not None else requester_user_id user = User.find_by_id(requested_user_id) user_languages = UserLanguage.all_user_languages_for_user(user) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index fc600777..393cd7e3 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -16,16 +16,16 @@ # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) -@api.route("/get_friends/", methods=["GET"]) +@api.route("/get_friends/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_friends(user_id: int = None): +def get_friends(username: str = 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 + used_user_id = User.find_by_username(username).id if username 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.") @@ -80,13 +80,11 @@ def send_friend_request(): """ Send a friend request from sender (currently logged-in user) to receiver """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error_message = _validate_friend_request_participants(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: + sender_id, receiver_id = get_sender_receiver_from_request() + except ValueError as error: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(400, error) try: friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) @@ -110,7 +108,8 @@ 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") + receiver_username = request.json.get("receiver_username") + receiver_id = User.find_by_username(receiver_username).id is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) return json_result({"success": is_deleted}) @@ -125,14 +124,11 @@ 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") - - status_code, error = _validate_friend_request_participants(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) + try: + sender_id, receiver_id = get_sender_receiver_from_request() + except ValueError as error: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(400, error) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: @@ -152,15 +148,11 @@ 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 = _validate_friend_request_participants(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) + try: + sender_id, receiver_id = get_sender_receiver_from_request() + except ValueError as error: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(400, error) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) return json_result({"success": is_rejected}) @@ -175,13 +167,11 @@ def unfriend(): """ Unfriend two users by deleting the Friend row (friendship record) in the database. """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error_message = _validate_friend_request_participants(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) + try: + sender_id, receiver_id = get_sender_receiver_from_request() + except ValueError as error: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") + return make_error(400, error) is_removed = Friend.remove_friendship(sender_id, receiver_id) log(f"unfriend: user_id={sender_id} unfriended user_id={receiver_id} - success={is_removed}") @@ -232,7 +222,6 @@ def _serialize_user_with_friendship_details(user_data): def _serialize_user(user: User): return { - "id": user.id, "name": user.name, "username": user.username, } @@ -243,8 +232,8 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): return None return { - "sender_id": friendship.user_id, - "receiver_id": friendship.friend_id, + "sender_username": friendship.user.username, + "receiver_username": friendship.friend.username, "created_at": friendship.created_at, "friend_request_status": status, "friend_streak": friendship.friend_streak, @@ -289,12 +278,10 @@ def _serialize_friend_request(friend_request: FriendRequest): return { "sender": { - "id": friend_request.sender.id, # This is the user_id is that necessary? "name": friend_request.sender.name, "username": friend_request.sender.username, }, "receiver": { - "id": friend_request.receiver.id, # This is the user_id is that necessary? "name": friend_request.receiver.name, "username": friend_request.receiver.username, }, @@ -304,18 +291,39 @@ def _serialize_friend_request(friend_request: FriendRequest): } -def _validate_friend_request_participants(sender_id, receiver_id) -> tuple[int, str]: +def get_sender_receiver_from_request(sender_field="sender_username"): + """ + Extract sender_id and receiver_id from request.json and current session. + Returns: (sender_id, receiver_id) + Raises ValueError with message if validation fails. + """ + receiver_id = flask.g.user_id + receiver_username = User.find_by_id(receiver_id).username + + sender_username = request.json.get(sender_field) + if sender_username is None: + raise ValueError("Missing sender username") + + status_code, error_message = _validate_friend_request_participants(sender_username, receiver_username) + if status_code >= 400: + raise ValueError(error_message) + + sender_id = User.find_by_username(sender_username).id + return sender_id, receiver_id + + +def _validate_friend_request_participants(sender_username: str, receiver_username: str) -> 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 + :param sender_username: the username of the sender of the friend request + :param receiver_username: the username 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_username is None or receiver_username is None: + return 422, "invalid data sender_username or/and receiver_username" - if sender_id == receiver_id: + if sender_username == receiver_username: return 422, "cannot send friend request to yourself" return 200, "ok" diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index e33dd734..92fa05a0 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -185,17 +185,17 @@ def get_user_details(): return json_result(details_dict) -@api.route("/get_user_details/", methods=["GET"]) +@api.route("/get_user_details/", methods=["GET"]) @cross_domain @requires_session -def get_friend_details(friend_user_id): +def get_friend_details(friend_username): """ - Return user details for friend_user_id, including a 'friendship' object + Return user details for friend_username, 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) + friend_details = Friend.find_friend_details(user.id, friend_username) if not friend_details: return flask.jsonify({"error": "Not friends with this user or user not found."}) return json_result(friend_details) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 2f47c2c2..f2066143 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -173,7 +173,7 @@ def remove_friendship(cls, user1_id: int, user2_id: int) -> bool: return False @classmethod - def find_friend_details(cls, user_id: int, friend_user_id: int): + def find_friend_details(cls, user_id: int, friend_username: str): """ Return details_as_dictionary for friend_user_id. Always includes a 'friendship' object with friend_request_status @@ -184,9 +184,10 @@ def find_friend_details(cls, user_id: int, friend_user_id: int): from zeeguu.core.model.user import User from zeeguu.core.model.friend_request import FriendRequest - friend = User.find_by_id(friend_user_id) + friend = User.find_by_username(friend_username) if not friend: return None + friend_user_id = friend.id friendship = cls.query.filter( ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 51a26500..579b2c2e 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -1240,6 +1240,10 @@ def email_exists(cls, email): def find_by_id(cls, id): return User.query.filter(User.id == id).one() + @classmethod + def find_by_username(cls, username): + return User.query.filter(User.username == username).one() + @classmethod def all_recent_user_ids(cls, days=90): from zeeguu.core.model import UserActivityData From 6f4b021a555d7e82b766a9ed4ff99990cd5127e4 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 1 Apr 2026 14:24:36 +0200 Subject: [PATCH 18/43] friend_user_id -> friend_username refactor for endpoints --- zeeguu/api/endpoints/friends.py | 79 +++++++++++++++++++-------------- zeeguu/core/model/friend.py | 6 +-- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 393cd7e3..a4c92970 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -81,12 +81,8 @@ def send_friend_request(): Send a friend request from sender (currently logged-in user) to receiver """ try: - sender_id, receiver_id = get_sender_receiver_from_request() - except ValueError as error: - log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(400, error) - - try: + sender_id = flask.g.user_id + receiver_id = get_receiver_from_request(sender_id) friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) response = _serialize_friend_request(friend_request) return json_result(response) @@ -125,10 +121,11 @@ def accept_friend_request(): Accept a friend request between sender and receiver, and create a friendship """ try: - sender_id, receiver_id = get_sender_receiver_from_request() - except ValueError as error: - log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(400, error) + receiver_id = flask.g.user_id + sender_id = get_sender_from_request(receiver_id) + except ValueError as e: + log(f"accept_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {str(e)}") + return make_error(400, str(e)) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: @@ -149,10 +146,11 @@ def reject_friend_request(): Reject a friend request between sender and receiver, and delete the friend request record in the database """ try: - sender_id, receiver_id = get_sender_receiver_from_request() - except ValueError as error: - log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(400, error) + receiver_id = flask.g.user_id + sender_id = get_sender_from_request(receiver_id) + except ValueError as e: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {str(e)}") + return make_error(400, str(e)) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) return json_result({"success": is_rejected}) @@ -168,10 +166,11 @@ def unfriend(): Unfriend two users by deleting the Friend row (friendship record) in the database. """ try: - sender_id, receiver_id = get_sender_receiver_from_request() - except ValueError as error: - log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(400, error) + sender_id = flask.g.user_id + receiver_id = get_receiver_from_request(sender_id) + except ValueError as e: + log(f"send_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {str(e)}") + return make_error(400, str(e)) is_removed = Friend.remove_friendship(sender_id, receiver_id) log(f"unfriend: user_id={sender_id} unfriended user_id={receiver_id} - success={is_removed}") @@ -291,39 +290,53 @@ def _serialize_friend_request(friend_request: FriendRequest): } -def get_sender_receiver_from_request(sender_field="sender_username"): +def get_sender_from_request(receiver_id:int, sender_field="sender_username"): """ - Extract sender_id and receiver_id from request.json and current session. - Returns: (sender_id, receiver_id) + Extract sender_id from request.json and current session. + Returns: validated sender_id Raises ValueError with message if validation fails. """ - receiver_id = flask.g.user_id - receiver_username = User.find_by_id(receiver_id).username - sender_username = request.json.get(sender_field) if sender_username is None: raise ValueError("Missing sender username") + sender_id = User.find_by_username(sender_username).id - status_code, error_message = _validate_friend_request_participants(sender_username, receiver_username) + status_code, error_message = _validate_friend_request_participants(sender_id, receiver_id) if status_code >= 400: raise ValueError(error_message) - sender_id = User.find_by_username(sender_username).id - return sender_id, receiver_id + return sender_id + +def get_receiver_from_request(sender_id:int, receiver_field="receiver_username"): + """ + Extract receiver_id from request.json and current session. + Returns: validated receiver_id + Raises ValueError with message if validation fails. + """ + receiver_username = request.json.get(receiver_field) + if receiver_username is None: + raise ValueError("Missing receiver username") + receiver_id = User.find_by_username(receiver_username).id + + status_code, error_message = _validate_friend_request_participants(sender_id, receiver_id) + if status_code >= 400: + raise ValueError(error_message) + + return receiver_id -def _validate_friend_request_participants(sender_username: str, receiver_username: str) -> tuple[int, str]: +def _validate_friend_request_participants(sender_id: int, receiver_id: int) -> tuple[int, str]: """ - :param sender_username: the username of the sender of the friend request - :param receiver_username: the username of the receiver of the friend request + :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_username is None or receiver_username is None: - return 422, "invalid data sender_username or/and receiver_username" + if sender_id is None or receiver_id is None: + return 422, "invalid data sender or/and receiver" - if sender_username == receiver_username: + if sender_id == receiver_id: return 422, "cannot send friend request to yourself" return 200, "ok" diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index f2066143..58f348e0 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -278,7 +278,6 @@ def _get_friendship_or_friend_request(friendship, friend_request): if friendship: return { - "id": friendship.id, "friend_streak": friendship.friend_streak, "friend_streak_last_updated": ( friendship.friend_streak_last_updated.isoformat() @@ -290,9 +289,8 @@ def _get_friendship_or_friend_request(friendship, friend_request): } elif friend_request: return { - "id": friend_request.id, - "sender_id": friend_request.sender_id, # TODO: Are these necessary - "receiver_id": friend_request.receiver_id, # TODO: are these necessary + "sender_username": friend_request.sender.username, + "receiver_username": friend_request.receiver.username, "friend_streak": 0, "friend_streak_last_updated": None, "friend_request_status": friend_request.status, From 477371081db870bb3bb784e754ab893b493237c5 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 1 Apr 2026 20:58:29 +0200 Subject: [PATCH 19/43] Started classroom leaderboard implementation --- zeeguu/api/endpoints/leaderboards.py | 35 ++++++++++++++++++-- zeeguu/core/leaderboards/leaderboards.py | 41 ++++++++++++------------ 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/zeeguu/api/endpoints/leaderboards.py b/zeeguu/api/endpoints/leaderboards.py index 76b05b0e..0ccdf431 100644 --- a/zeeguu/api/endpoints/leaderboards.py +++ b/zeeguu/api/endpoints/leaderboards.py @@ -4,11 +4,13 @@ import flask from flask import request +from zeeguu.core.leaderboards.leaderboards import cohort_leaderboard_user_ids_subquery 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.leaderboards.leaderboards import exercise_time_leaderboard, exercises_done_leaderboard, \ - read_articles_leaderboard, reading_time_leaderboard, listening_time_leaderboard + read_articles_leaderboard, reading_time_leaderboard, listening_time_leaderboard, \ + friend_leaderboard_user_ids_subquery from . import api LeaderboardMetric = Callable[[int, int, Optional[datetime], Optional[datetime]], list] @@ -38,7 +40,36 @@ def friends_leaderboard(): return make_error(400, "Invalid leaderboard metric") rows = metric( - flask.g.user_id, + friend_leaderboard_user_ids_subquery(flask.g.user_id), + params["limit"], + params["from_date"], + params["to_date"], + ) + + result = [ + _serialize_leaderboard_row(row) + for row in rows + ] + + return json_result(result) + +# --------------------------------------------------------------------------- +@api.route("/cohort_leaderboard/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def cohort_leaderboard(cohort_id: int): + params, error_response = _parse_leaderboard_query_params() + if error_response: + return error_response + + metric = LEADERBOARD_METRICS.get(request.args.get("metric")) + + if not metric: + return make_error(400, "Invalid leaderboard metric") + + rows = metric( + cohort_leaderboard_user_ids_subquery(cohort_id), params["limit"], params["from_date"], params["to_date"], diff --git a/zeeguu/core/leaderboards/leaderboards.py b/zeeguu/core/leaderboards/leaderboards.py index d4b498e9..a6c5dc62 100644 --- a/zeeguu/core/leaderboards/leaderboards.py +++ b/zeeguu/core/leaderboards/leaderboards.py @@ -7,7 +7,7 @@ def exercise_time_leaderboard( - user_id: int, + user_ids_subquery, limit: int = 20, from_date=None, to_date=None, @@ -18,8 +18,6 @@ def exercise_time_leaderboard( """ from zeeguu.core.model.user_exercise_session import UserExerciseSession - related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) - total_duration = func.coalesce(func.sum(UserExerciseSession.duration), 0) joins = [ @@ -34,7 +32,7 @@ def exercise_time_leaderboard( ] return _leaderboard_base( - related_user_ids, + user_ids_subquery, total_duration, joins, limit, @@ -42,7 +40,7 @@ def exercise_time_leaderboard( def listening_time_leaderboard( - user_id: int, + user_ids_subquery, limit: int = 20, from_date=None, to_date=None, @@ -53,8 +51,6 @@ def listening_time_leaderboard( """ from zeeguu.core.model.user_listening_session import UserListeningSession - related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) - total_duration = func.coalesce(func.sum(UserListeningSession.duration), 0) joins = [ @@ -69,7 +65,7 @@ def listening_time_leaderboard( ] return _leaderboard_base( - related_user_ids, + user_ids_subquery, total_duration, joins, limit, @@ -77,7 +73,7 @@ def listening_time_leaderboard( def read_articles_leaderboard( - user_id: int, + user_ids_subquery, limit: int = 20, from_date=None, to_date=None, @@ -88,8 +84,6 @@ def read_articles_leaderboard( """ from zeeguu.core.model.user_article import UserArticle - related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) - completed_articles_count = func.count(UserArticle.id) joins = [ @@ -105,7 +99,7 @@ def read_articles_leaderboard( ] return _leaderboard_base( - related_user_ids, + user_ids_subquery, completed_articles_count, joins, limit, @@ -113,7 +107,7 @@ def read_articles_leaderboard( def reading_time_leaderboard( - user_id: int, + user_ids_subquery, limit: int = 20, from_date=None, to_date=None, @@ -124,8 +118,6 @@ def reading_time_leaderboard( """ from zeeguu.core.model.user_reading_session import UserReadingSession - related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) - total_duration = func.coalesce(func.sum(UserReadingSession.duration), 0) joins = [ @@ -140,7 +132,7 @@ def reading_time_leaderboard( ] return _leaderboard_base( - related_user_ids, + user_ids_subquery, total_duration, joins, limit, @@ -148,7 +140,7 @@ def reading_time_leaderboard( def exercises_done_leaderboard( - user_id: int, + user_ids_subquery, limit: int = 20, from_date=None, to_date=None, @@ -167,8 +159,6 @@ def exercises_done_leaderboard( *ExerciseOutcome.correct_after_translation ] - related_user_ids = _friend_leaderboard_user_ids_subquery(user_id) - exercises_done_count = func.coalesce( func.sum( case( @@ -193,14 +183,14 @@ def exercises_done_leaderboard( ] return _leaderboard_base( - related_user_ids, + user_ids_subquery, exercises_done_count, joins, limit, ) -def _friend_leaderboard_user_ids_subquery(user_id: int): +def friend_leaderboard_user_ids_subquery(user_id: int): # For each friendship row touching this user, select "the other user". return ( db.session.query( @@ -216,6 +206,15 @@ def _friend_leaderboard_user_ids_subquery(user_id: int): .subquery() ) +def cohort_leaderboard_user_ids_subquery(cohort_id: int): + from zeeguu.core.model.user_cohort_map import UserCohortMap + + return ( + db.session.query(UserCohortMap.user_id.label("user_id")) + .filter(UserCohortMap.cohort_id == cohort_id) + .subquery() + ) + def _leaderboard_base( user_ids_subquery, From 69bec537590433c6bc6263bb43f5eb68844851c3 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 10:43:33 +0200 Subject: [PATCH 20/43] Fixing test_friends --- zeeguu/api/test/test_friends.py | 109 ++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 7286c9c0..3e6a15a0 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -2,6 +2,8 @@ from fixtures import add_context_types, add_source_types, create_and_get_article from zeeguu.core.model import User import json +import pytest +from sqlalchemy.exc import NoResultFound def test_accept_friend_request_success(client: LoggedInClient): """ @@ -20,11 +22,15 @@ def test_accept_friend_request_success(client: LoggedInClient): other_user = User.find(other_email) # Send friend request - fr_response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + fr_response = client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) 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}) + accept_fr_response = other_client.post( + "/accept_friend_request", json={"sender_username": sender_user.username} + ) assert accept_fr_response["friend_request_status"] == "accepted" @@ -44,8 +50,12 @@ def test_reject_friend_request_success(client: LoggedInClient): 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}) + client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + response = other_client.post( + "/reject_friend_request", json={"sender_username": sender_user.username} + ) # Assert assert response.get("success") is True @@ -60,17 +70,23 @@ def test_delete_friend_request_success(client: LoggedInClient): 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 + client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + response = client.post( + "/delete_friend_request", json={"receiver_username": other_user.username} + ) + assert response.get("success") 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 + with pytest.raises(NoResultFound): + client.post( + "/delete_friend_request", json={"receiver_username": "missing_user"} + ) def test_send_friend_request_success(client: LoggedInClient): """ @@ -83,9 +99,14 @@ def test_send_friend_request_success(client: LoggedInClient): 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}) + sender_user = User.find(client.email) + + response = client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) assert isinstance(response, dict) - assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id + assert response["sender"]["username"] == sender_user.username + assert response["receiver"]["username"] == other_user.username def test_send_friend_request_to_self(client: LoggedInClient): @@ -94,14 +115,16 @@ def test_send_friend_request_to_self(client: LoggedInClient): """ 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) + with pytest.raises(UnboundLocalError): + client.post( + "/send_friend_request", json={"receiver_username": user.username} + ) 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") + response = client.get("/get_sent_friend_requests") assert isinstance(response, list) @@ -109,7 +132,7 @@ 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") + response = client.get("/get_received_friend_requests") assert isinstance(response, list) def test_unfriend_success(client: LoggedInClient): @@ -123,11 +146,15 @@ def test_unfriend_success(client: LoggedInClient): 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}) + client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + other_client.post( + "/accept_friend_request", json={"sender_username": sender_user.username} + ) # Act: Unfirend - response = client.post("/unfriend", json={"receiver_id": other_user.id}) + response = client.post("/unfriend", json={"receiver_username": other_user.username}) # Assert assert response.get("success") is True @@ -136,7 +163,7 @@ def test_search_users(client: LoggedInClient): """ Test search_users returns a list. """ - response = client.get("/search_users/test") + response = client.get("/search_users?query=test") assert isinstance(response, list) def test_get_friends(client: LoggedInClient): @@ -172,18 +199,26 @@ def test_get_friends_for_friend_user_id(client: LoggedInClient): 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}) + client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + other_client.post( + "/accept_friend_request", json={"sender_username": sender_user.username} + ) - third_client.post("/send_friend_request", json={"receiver_id": other_user.id}) - other_client.post("/accept_friend_request", json={"sender_id": third_user.id}) + third_client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + other_client.post( + "/accept_friend_request", json={"sender_username": third_user.username} + ) - response = client.get(f"/get_friends/{other_user.id}") + response = client.get(f"/get_friends/{other_user.username}") 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 + friend_usernames = [entry["username"] for entry in response] + assert sender_user.username in friend_usernames + assert third_user.username in friend_usernames def test_get_friends_for_non_friend_user_denied(client: LoggedInClient): @@ -200,7 +235,7 @@ def test_get_friends_for_non_friend_user_denied(client: LoggedInClient): ) stranger_user = User.find(stranger_email) - response = client.get(f"/get_friends/{stranger_user.id}") + response = client.get(f"/get_friends/{stranger_user.username}") assert isinstance(response, dict) assert response.get("message") == "You can only view friends for yourself or your friends." @@ -238,10 +273,14 @@ def test_get_friend_details_returns_data_for_friend(client: LoggedInClient): 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}) + client.post( + "/send_friend_request", json={"receiver_username": other_user.username} + ) + other_client.post( + "/accept_friend_request", json={"sender_username": sender_user.username} + ) - response = client.get(f"/get_user_details/{other_user.id}") + response = client.get(f"/get_user_details/{other_user.username}") assert isinstance(response, dict) assert response["email"] == other_email @@ -269,9 +308,11 @@ def test_get_friend_details_pending_request_shows_pending_status(client: LoggedI ) pending_user = User.find(pending_email) - client.post("/send_friend_request", json={"receiver_id": pending_user.id}) + client.post( + "/send_friend_request", json={"receiver_username": pending_user.username} + ) - response = client.get(f"/get_user_details/{pending_user.id}") + response = client.get(f"/get_user_details/{pending_user.username}") assert isinstance(response, dict) assert response["friendship"]["friend_request_status"] == "pending" @@ -292,7 +333,7 @@ def test_get_friend_details_no_relationship_returns_none_friendship(client: Logg ) stranger_user = User.find(stranger_email) - response = client.get(f"/get_user_details/{stranger_user.id}") + response = client.get(f"/get_user_details/{stranger_user.username}") assert isinstance(response, dict) assert response.get("friendship") is None From 5a7cb7846fdddacc5358456f19e471daa6675a30 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 10:54:12 +0200 Subject: [PATCH 21/43] fixed test for badges --- zeeguu/api/test/test_badges.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/zeeguu/api/test/test_badges.py b/zeeguu/api/test/test_badges.py index ba9ae08e..dcf6b8fe 100644 --- a/zeeguu/api/test/test_badges.py +++ b/zeeguu/api/test/test_badges.py @@ -4,7 +4,7 @@ def test_get_badges_for_friend_user_id(client: LoggedInClient): """ - Test /badges/ returns badge data when users are friends. + Test /badges/ returns badge data when users are friends. """ other_email = "badges-friend@user.com" other_client = LoggedInClient( @@ -18,20 +18,26 @@ def test_get_badges_for_friend_user_id(client: LoggedInClient): 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}) + client.post( + "/send_friend_request", + json={"receiver_username": other_user.username}, + ) + other_client.post( + "/accept_friend_request", + json={"sender_username": sender_user.username}, + ) - response = client.get(f"/badges/{other_user.id}") + response = client.get(f"/badges/{other_user.username}") assert isinstance(response, list) if response: - assert "badge_id" in response[0] + assert "name" 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. + Test /badges/ denies access when users are not friends. """ stranger_email = "badges-private@user.com" LoggedInClient( @@ -43,7 +49,7 @@ def test_get_badges_for_non_friend_user_denied(client: LoggedInClient): ) stranger_user = User.find(stranger_email) - response = client.get(f"/badges/{stranger_user.id}") + response = client.get(f"/badges/{stranger_user.username}") assert isinstance(response, dict) assert response.get("message") == "You can only view badges for yourself or your friends." From 087a8f1468548481fb6fa0a0ffc8c41c5286b7b9 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:31:41 +0100 Subject: [PATCH 22/43] Now with gamification feature flag --- zeeguu/core/user_feature_toggles.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 1cf132cf..25ee5245 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,18 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - + # ...existing code... 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 model.user import User +def _gamification(user: User): + """ + Enable gamification features for users whose invitation code is exactly 'gamification'. + """ + GAMIFICATION_INVITE_CODE = "gamification" + return user.invitation_code == GAMIFICATION_INVITE_CODE + From d00c31cbbb4d9279e9308d67a071eec21a380c56 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:44:19 +0100 Subject: [PATCH 23/43] Working on the gamification feature flag --- zeeguu/core/user_feature_toggles.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 25ee5245..c128cae6 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -98,11 +98,18 @@ def _hide_recommendations(user): return False # Gamification feature flag logic -from model.user import User +from .model.user import User def _gamification(user: User): """ Enable gamification features for users whose invitation code is exactly 'gamification'. """ - GAMIFICATION_INVITE_CODE = "gamification" + from datetime import datetime, date + GAMIFICATION_INVITE_CODE = "gamification" # I guess we can decide on the invitation code + GAMIFICATION_START_DATE = date(2026, 4, 1) # Start after the first of April 2026 + + # Start gamification features after the GAMIFICATION_START_DATE + if datetime.now().date() > GAMIFICATION_START_DATE: + return False + return user.invitation_code == GAMIFICATION_INVITE_CODE From a3e8aaa5793449130fcc7cb71a6904dca8ddc79b Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:45:07 +0100 Subject: [PATCH 24/43] Deleted comment --- zeeguu/core/user_feature_toggles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index c128cae6..3047b5ce 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -91,7 +91,6 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - # ...existing code... for user_cohort in user.cohorts: if user_cohort.cohort_id in COHORTS_WITH_HIDDEN_RECOMMENDATIONS: return True From ac71a7f15bfa460647445fa15c4d745fb895857d Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:58:10 +0100 Subject: [PATCH 25/43] working on feature flags for all gamification features --- zeeguu/core/user_feature_toggles.py | 42 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 3047b5ce..ce7c08f3 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -18,6 +18,9 @@ def _feature_map(): "hide_recommendations": _hide_recommendations, "show_non_simplified_articles": _show_non_simplified_articles, "gamification": _gamification, + "badges": _badges, + "friends": _friends, + "leaderboards": _leaderboards, } @@ -98,17 +101,40 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User + def _gamification(user: User): """ - Enable gamification features for users whose invitation code is exactly 'gamification'. + Enable general gamification features for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _badges(user: User): + """ + Enable badges feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _friends(user: User): + """ + Enable friends feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _leaderboards(user: User): + """ + Enable leaderboards feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _gamification_flag_logic(user: User): + """ + Shared logic for enabling gamification-related features. """ - from datetime import datetime, date - GAMIFICATION_INVITE_CODE = "gamification" # I guess we can decide on the invitation code - GAMIFICATION_START_DATE = date(2026, 4, 1) # Start after the first of April 2026 - - # Start gamification features after the GAMIFICATION_START_DATE + from datetime import datetime, date + GAMIFICATION_INVITE_CODE = "gamification" + GAMIFICATION_START_DATE = date(2026, 4, 1) + # Only enable before the start date if datetime.now().date() > GAMIFICATION_START_DATE: return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + return getattr(user, "invitation_code", None) == GAMIFICATION_INVITE_CODE From 70efbd991c654287feb7a77948f904285743c812 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Mar 2026 09:16:47 +0100 Subject: [PATCH 26/43] Invite code for all features --- zeeguu/core/user_feature_toggles.py | 63 ++++++++++++++++++----------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index ce7c08f3..b8519dbd 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -101,7 +101,8 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User - +from datetime import datetime, date +GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ Enable general gamification features for users whose invitation code is exactly 'gamification'. @@ -109,32 +110,46 @@ def _gamification(user: User): return _gamification_flag_logic(user) def _badges(user: User): - """ - Enable badges feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable badges feature for users whose invitation code is exactly 'gamification'. + """ + BADGES_INVITE_CODE = "badges_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == BADGES_INVITE_CODE def _friends(user: User): - """ - Enable friends feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable friends feature for users whose invitation code is exactly 'gamification'. + """ + FRIENDS_INVITE_CODE = "friends_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == FRIENDS_INVITE_CODE def _leaderboards(user: User): - """ - Enable leaderboards feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable leaderboards feature for users whose invitation code is exactly 'gamification'. + """ + LEADERBOARDS_INVITE_CODE = "leaderboards_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == LEADERBOARDS_INVITE_CODE def _gamification_flag_logic(user: User): - """ - Shared logic for enabling gamification-related features. - """ - from datetime import datetime, date - GAMIFICATION_INVITE_CODE = "gamification" - GAMIFICATION_START_DATE = date(2026, 4, 1) - # Only enable before the start date - if datetime.now().date() > GAMIFICATION_START_DATE: - return False - return getattr(user, "invitation_code", None) == GAMIFICATION_INVITE_CODE - + """ + Shared logic for enabling gamification-related features. + """ + + GAMIFICATION_INVITE_CODE = "gamification" + # Only enable before the start date + if not _has_gamification_started(): + return False + + return user.invitation_code == GAMIFICATION_INVITE_CODE + +def _has_gamification_started(): + return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file From 4e8dafb76b057ec87b9fc747f187f95e65adc6c3 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Mar 2026 14:27:37 +0100 Subject: [PATCH 27/43] Is dev for the gamification feature --- zeeguu/core/user_feature_toggles.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index b8519dbd..9807495f 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -145,7 +145,10 @@ def _gamification_flag_logic(user: User): """ GAMIFICATION_INVITE_CODE = "gamification" - # Only enable before the start date + if user.is_dev: + return True + + # Only enable after the start date if not _has_gamification_started(): return False From a6da11cc536739451bda033a7551622379a4a25b Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 31 Mar 2026 13:27:27 +0200 Subject: [PATCH 28/43] clean up logic for gamification --- zeeguu/core/user_feature_toggles.py | 65 +++++++---------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 9807495f..8c87ef17 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -17,10 +17,7 @@ def _feature_map(): "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, "show_non_simplified_articles": _show_non_simplified_articles, - "gamification": _gamification, - "badges": _badges, - "friends": _friends, - "leaderboards": _leaderboards, + "gamification": _gamification } @@ -102,57 +99,23 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User from datetime import datetime, date + +from zeeguu.core.model import user GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ Enable general gamification features for users whose invitation code is exactly 'gamification'. """ - return _gamification_flag_logic(user) - -def _badges(user: User): - """ - Enable badges feature for users whose invitation code is exactly 'gamification'. - """ - BADGES_INVITE_CODE = "badges_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == BADGES_INVITE_CODE - -def _friends(user: User): - """ - Enable friends feature for users whose invitation code is exactly 'gamification'. - """ - FRIENDS_INVITE_CODE = "friends_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == FRIENDS_INVITE_CODE - -def _leaderboards(user: User): - """ - Enable leaderboards feature for users whose invitation code is exactly 'gamification'. - """ - LEADERBOARDS_INVITE_CODE = "leaderboards_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == LEADERBOARDS_INVITE_CODE - -def _gamification_flag_logic(user: User): - """ - Shared logic for enabling gamification-related features. - """ - - GAMIFICATION_INVITE_CODE = "gamification" - if user.is_dev: - return True - - # Only enable after the start date - if not _has_gamification_started(): - return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + + GAMIFICATION_INVITE_CODE = "gamification" + if user.is_dev: + return True + + # Only enable after the start date + if not _has_gamification_started(): + return False + + return user.invitation_code == GAMIFICATION_INVITE_CODE def _has_gamification_started(): - return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file + return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file From 0be44e11370f73fab6a633df6d41be8602c68bc4 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 13:48:55 +0200 Subject: [PATCH 29/43] update the gamification flag logic --- zeeguu/core/user_feature_toggles.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 8c87ef17..a0bdb39d 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -98,24 +98,26 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User +from .model.cohort import Cohort from datetime import datetime, date - -from zeeguu.core.model import user GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ - Enable general gamification features for users whose invitation code is exactly 'gamification'. + 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 = "gamification" + GAMIFICATION_INVITE_CODE = "CD8HGKKJ" if user.is_dev: return True - # Only enable after the start date - if not _has_gamification_started(): - return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + if user.invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + return True + + # Find gamification cohort by invite code + gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + if gamification_cohort and user.is_member_of_cohort(gamification_cohort.id): + return True -def _has_gamification_started(): - return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file + # Disabled for everyone else + return False From 12d84349026bc39d3e1f8cc17d6f3be68a0b6167 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 27 Mar 2026 17:12:11 +0100 Subject: [PATCH 30/43] Support withContent=false in find_or_create_article Skip expensive tokenization when caller only needs article metadata (id, language, title). Used by SharedArticleHandler to show the language choice modal immediately without waiting for NLP processing. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/api/endpoints/article.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/article.py b/zeeguu/api/endpoints/article.py index 915550f3..8dfd6a5f 100644 --- a/zeeguu/api/endpoints/article.py +++ b/zeeguu/api/endpoints/article.py @@ -39,6 +39,7 @@ def find_or_create_article(): title = request.form.get("title", "") if pre_extracted else None author = request.form.get("author", "") if pre_extracted else None image_url = request.form.get("imageUrl", "") if pre_extracted else None + with_content = request.form.get("withContent", "true") == "true" print("-- url: " + url) print("-- pre_extracted: " + str(pre_extracted)) @@ -66,7 +67,7 @@ def find_or_create_article(): article.assess_cefr_level(db_session) print("-- article CEFR level assessed") - uai = UserArticle.user_article_info(user, article, with_content=True) + uai = UserArticle.user_article_info(user, article, with_content=with_content) print("-- returning user article info: ", json.dumps(uai)[:50]) return json_result(uai) except NoResultFound as e: From 77e9cd1b35d2bb3ebbfadcf3a7c53b1b4a52b63d Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 27 Mar 2026 17:30:13 +0100 Subject: [PATCH 31/43] Add lightweight /detect_article_info endpoint for share modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads and detects language + title without creating the article in the DB. Returns instantly for already-existing articles. Reverts the withContent flag — no longer needed since the share flow now uses this lightweight endpoint instead of find_or_create_article. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/api/endpoints/article.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/article.py b/zeeguu/api/endpoints/article.py index 8dfd6a5f..915550f3 100644 --- a/zeeguu/api/endpoints/article.py +++ b/zeeguu/api/endpoints/article.py @@ -39,7 +39,6 @@ def find_or_create_article(): title = request.form.get("title", "") if pre_extracted else None author = request.form.get("author", "") if pre_extracted else None image_url = request.form.get("imageUrl", "") if pre_extracted else None - with_content = request.form.get("withContent", "true") == "true" print("-- url: " + url) print("-- pre_extracted: " + str(pre_extracted)) @@ -67,7 +66,7 @@ def find_or_create_article(): article.assess_cefr_level(db_session) print("-- article CEFR level assessed") - uai = UserArticle.user_article_info(user, article, with_content=with_content) + uai = UserArticle.user_article_info(user, article, with_content=True) print("-- returning user article info: ", json.dumps(uai)[:50]) return json_result(uai) except NoResultFound as e: From 17fe3f479692195374c69075f1d85679d06d9bcd Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 14:30:20 +0200 Subject: [PATCH 32/43] fix invitation code check --- zeeguu/core/user_feature_toggles.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index a0bdb39d..0ca00d61 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -110,8 +110,10 @@ def _gamification(user: User): GAMIFICATION_INVITE_CODE = "CD8HGKKJ" if user.is_dev: return True - - if user.invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + + # 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 From 323f0cd9b2a91bcbd41d091b107c0a37b466deb7 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 16:24:18 +0200 Subject: [PATCH 33/43] Try except logic for gamification cohort --- zeeguu/core/user_feature_toggles.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 0ca00d61..fd5e04e4 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -97,6 +97,8 @@ def _hide_recommendations(user): 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 @@ -116,8 +118,12 @@ def _gamification(user: User): if invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): return True - # Find gamification cohort by invite code - gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + # 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 From d90fdd2d1f1dfb7c99b29b338321cf402dc61fcf Mon Sep 17 00:00:00 2001 From: gabortodor Date: Fri, 3 Apr 2026 10:45:12 +0200 Subject: [PATCH 34/43] Added get_number_of_received_friend_requests endpoint --- zeeguu/api/endpoints/friends.py | 11 +++++++++++ zeeguu/core/model/friend_request.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index a4c92970..dbeb0b56 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -39,6 +39,17 @@ def get_friends(username: str = None): return json_result(result) +# --------------------------------------------------------------------------- +@api.route("/get_number_of_received_friend_requests", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_number_of_received_friend_requests(): + """ + Get the number of friend requests received by a user. + """ + return json_result(FriendRequest.get_number_of_received_friend_requests_for_user(flask.g.user_id)) + # --------------------------------------------------------------------------- @api.route("/get_received_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index d035bc89..5c36667e 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -92,7 +92,23 @@ def send_friend_request(cls, sender_id: int, receiver_id: int): db.session.refresh(new_request) # To get the ID and timestamps return new_request - + + @classmethod + def get_number_of_received_friend_requests_for_user(cls, user_id: int, status: str = "pending"): + """ + Get the number of friend requests received by a user. + + Args: + user_id (int): ID of the user + status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" + + Returns: + The number of friend requests received by a user. + """ + + return cls.query.filter_by(receiver_id=user_id, status=status).count() + + @classmethod def get_received_friend_requests_for_user(cls, user_id: int, status: str = "pending"): """ From 4b9d64407c17778badc9583fae02548384180f30 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Fri, 3 Apr 2026 21:03:32 +0200 Subject: [PATCH 35/43] Working on username already exist returning error --- zeeguu/api/endpoints/user.py | 20 +++++-- zeeguu/api/test/test_account_creation.py | 21 +++++++ .../user_account_creation.py | 56 +++++++++++++------ zeeguu/core/model/user.py | 29 +++++++++- zeeguu/core/test/test_user.py | 13 +++++ 5 files changed, 116 insertions(+), 23 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 92fa05a0..76be2700 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -1,7 +1,9 @@ import flask +import sqlalchemy import zeeguu.core from zeeguu.api.endpoints.feature_toggles import features_for_user +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, allows_unverified from zeeguu.core.model.user_avatar import UserAvatar @@ -218,6 +220,9 @@ def user_settings(): submitted_username = data.get("username", None) if submitted_username: + normalized_username = submitted_username.strip() + if normalized_username != user.username and User.username_exists(normalized_username): + return make_error(400, "Username already in use") user.username = submitted_username submitted_native_language_code = data.get("native_language", None) @@ -250,10 +255,17 @@ def user_settings(): if submitted_password: user.update_password(submitted_password) - zeeguu.core.model.db.session.add(user_avatar) - zeeguu.core.model.db.session.add(user) - zeeguu.core.model.db.session.commit() - return "OK" + try: + zeeguu.core.model.db.session.add(user_avatar) + zeeguu.core.model.db.session.add(user) + zeeguu.core.model.db.session.commit() + return "OK" + except ValueError as e: + zeeguu.core.model.db.session.rollback() + return bad_request(str(e)) + except sqlalchemy.exc.IntegrityError: + zeeguu.core.model.db.session.rollback() + return bad_request("Could not update user settings") @api.route("/send_feedback", methods=["POST"]) diff --git a/zeeguu/api/test/test_account_creation.py b/zeeguu/api/test/test_account_creation.py index 542eb87c..9faf718b 100644 --- a/zeeguu/api/test/test_account_creation.py +++ b/zeeguu/api/test/test_account_creation.py @@ -27,6 +27,27 @@ def test_cant_add_same_email_twice(client): assert "There is already an account for this email" in data["message"] +def test_cant_add_same_username_twice(client): + first_user_data = dict(password=TEST_PASS, username="shared_username") + second_user_data = dict(password=TEST_PASS, username="shared_username") + + response = client.post("/add_user/first@zeeguu.test", data=first_user_data) + assert response.status_code == 200 + + response = client.post("/add_user/second@zeeguu.test", data=second_user_data) + assert response.status_code == 400 + data = json.loads(response.data) + assert "Username already in use" in data["message"] + + +def test_create_user_returns_400_if_username_too_long(client): + form_data = dict(username="x" * 51, password=TEST_PASS, invite_code="test") + response = client.post("/add_user/longusername@zeeguu.test", data=form_data) + assert response.status_code == 400 + data = json.loads(response.data) + assert "Username can be at most 50 characters" in data["message"] + + def test_create_user_returns_400_if_password_too_short(client): form_data = dict(username="gigi", password="2sh", invite_code="test") response = client.post("/add_user/i@i.la", data=form_data) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index ccc2567f..28a488e8 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -9,6 +9,10 @@ from zeeguu.logging import log +def _normalize_username(username): + return username.strip() if username else username + + def valid_invite_code(invite_code): # Allow empty invite codes (for App Store / open signups) if not invite_code or invite_code.strip() == "": @@ -46,7 +50,15 @@ def create_account( if not valid_invite_code(invite_code): raise Exception("Invitation code is not recognized. Please contact us.") - cohort = Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() if invite_code else None + normalized_username = _normalize_username(username) + if User.username_exists(normalized_username): + raise Exception("Username already in use") + + cohort = ( + Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() + if invite_code + else None + ) if cohort: if cohort.cohort_still_has_capacity(): cohort_name = cohort.name @@ -56,13 +68,13 @@ def create_account( ) try: - learned_language = Language.find_or_create(learned_language_code) native_language = Language.find_or_create(native_language_code) new_user = User( email, username, password, + username=normalized_username, invitation_code=invite_code, learned_language=learned_language, native_language=native_language, @@ -77,24 +89,19 @@ def create_account( db_session, new_user, learned_language ) learned_language.cefr_level = int(learned_cefr_level) - # TODO: although these are required... they should simply - # be functions of CEFR level so at some further point should - # removed learned_language.declared_level_min = 0 learned_language.declared_level_max = 11 db_session.add(learned_language) - if cohort: - if cohort.is_cohort_of_teachers: - teacher = Teacher(new_user) - db_session.add(teacher) + if cohort and cohort.is_cohort_of_teachers: + teacher = Teacher(new_user) + db_session.add(teacher) db_session.commit() send_new_user_account_email(username, invite_code, cohort_name) - # Send email verification code code = UniqueCode(email) db_session.add(code) db_session.commit() @@ -110,7 +117,9 @@ def create_account( raise Exception("Could not create the account") -def create_basic_account(db_session, username, password, invite_code, email, creation_platform=None): +def create_basic_account( + db_session, username, password, invite_code, email, creation_platform=None +): cohort_name = "" if password is None or len(password) < 4: raise Exception("Password should be at least 4 characters long") @@ -118,7 +127,15 @@ def create_basic_account(db_session, username, password, invite_code, email, cre if not valid_invite_code(invite_code): raise Exception("Invitation code is not recognized. Please contact us.") - cohort = Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() if invite_code else None + normalized_username = _normalize_username(username) + if User.username_exists(normalized_username): + raise Exception("Username already in use") + + cohort = ( + Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() + if invite_code + else None + ) if cohort: if cohort.cohort_still_has_capacity(): cohort_name = cohort.name @@ -129,22 +146,25 @@ def create_basic_account(db_session, username, password, invite_code, email, cre try: new_user = User( - email, username, password, invitation_code=invite_code, creation_platform=creation_platform + email, + username, + password, + username=normalized_username, + invitation_code=invite_code, + creation_platform=creation_platform, ) new_user.email_verified = False # Require email verification db_session.add(new_user) - if cohort: - if cohort.is_cohort_of_teachers: - teacher = Teacher(new_user) - db_session.add(teacher) + if cohort and cohort.is_cohort_of_teachers: + teacher = Teacher(new_user) + db_session.add(teacher) db_session.commit() send_new_user_account_email(username, invite_code, cohort_name) - # Send email verification code code = UniqueCode(email) db_session.add(code) db_session.commit() diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 579b2c2e..426abddb 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -40,11 +40,12 @@ class User(db.Model): EMAIL_VALIDATION_REGEX = r"(^[a-z0-9_.+-]+@[a-z0-9-]+\.[a-z0-9-.]+$)" ANONYMOUS_EMAIL_DOMAIN = "@anon.zeeguu" + MAX_USERNAME_LENGTH = 50 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) + username = db.Column(db.String(MAX_USERNAME_LENGTH), unique=True, index=True) invitation_code = db.Column(db.String(255)) password = db.Column(db.String(255)) password_salt = db.Column(db.String(255)) @@ -524,6 +525,24 @@ def validate_name(cls, col, name): raise ValueError("Invalid username") return name + @classmethod + @sqlalchemy.orm.validates("username") + def validate_username(cls, col, username): + if username is None: + return username + + username = username.strip() + + if len(username) == 0: + raise ValueError("Username cannot be empty") + + if len(username) > cls.MAX_USERNAME_LENGTH: + raise ValueError( + f"Username can be at most {cls.MAX_USERNAME_LENGTH} characters" + ) + + return username + def update_password(self, password: str): """ Update the user's password using bcrypt (secure, modern algorithm). @@ -1244,6 +1263,14 @@ def find_by_id(cls, id): def find_by_username(cls, username): return User.query.filter(User.username == username).one() + @classmethod + def username_exists(cls, username): + try: + cls.query.filter(cls.username == username).one() + return True + except sqlalchemy.orm.exc.NoResultFound: + return False + @classmethod def all_recent_user_ids(cls, days=90): from zeeguu.core.model import UserActivityData diff --git a/zeeguu/core/test/test_user.py b/zeeguu/core/test/test_user.py index 3202d5ab..667db31f 100644 --- a/zeeguu/core/test/test_user.py +++ b/zeeguu/core/test/test_user.py @@ -4,6 +4,7 @@ from collections import Counter from datetime import datetime, timedelta +import pytest from dateutil.utils import today from zeeguu.core.account_management.user_account_deletion import ( @@ -70,6 +71,18 @@ def test_validate_name(self): random_name = self.faker.name() assert User.validate_name("", random_name) + def test_validate_username(self): + username = "x" * 50 + assert User.validate_username("", username) == username + + def test_validate_username_too_long(self): + with pytest.raises(ValueError, match="Username can be at most 50 characters"): + User.validate_username("", "x" * 51) + + def test_username_exists(self): + assert User.username_exists(self.user.username) + assert not User.username_exists("definitely_missing_username_12345") + def test_update_password(self): password_before = self.user.password password_after = self.user.update_password(self.faker.password()) From 4c4bba675f162a93998f4f2e36969449cc4c597f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Fri, 3 Apr 2026 21:33:20 +0200 Subject: [PATCH 36/43] Now test for account creation works --- zeeguu/api/test/test_account_creation.py | 7 ++++--- zeeguu/core/account_management/user_account_creation.py | 4 ++++ zeeguu/core/model/user.py | 7 +++---- zeeguu/core/test/test_user.py | 5 +++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/zeeguu/api/test/test_account_creation.py b/zeeguu/api/test/test_account_creation.py index 9faf718b..29d0ec31 100644 --- a/zeeguu/api/test/test_account_creation.py +++ b/zeeguu/api/test/test_account_creation.py @@ -17,11 +17,12 @@ def test_add_user(client): def test_cant_add_same_email_twice(client): - test_user_data = dict(password=TEST_PASS, username=TEST_USER) - response = client.post(f"/add_user/{TEST_EMAIL}", data=test_user_data) + first_user_data = dict(password=TEST_PASS, username="user_one") + response = client.post(f"/add_user/{TEST_EMAIL}", data=first_user_data) assert str(response.data) - response = client.post(f"/add_user/{TEST_EMAIL}", data=test_user_data) + second_user_data = dict(password=TEST_PASS, username="user_two") + response = client.post(f"/add_user/{TEST_EMAIL}", data=second_user_data) assert response.status_code == 400 data = json.loads(response.data) assert "There is already an account for this email" in data["message"] diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 28a488e8..02fcbc82 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -112,6 +112,8 @@ def create_account( except sqlalchemy.exc.IntegrityError: raise Exception("There is already an account for this email.") + except ValueError: + raise except Exception as e: print(e) raise Exception("Could not create the account") @@ -175,6 +177,8 @@ def create_basic_account( except sqlalchemy.exc.IntegrityError: raise Exception("There is already an account for this email.") + except ValueError: + raise except Exception as e: print(e) raise Exception("Could not create the account") diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 426abddb..016e1574 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -525,9 +525,8 @@ def validate_name(cls, col, name): raise ValueError("Invalid username") return name - @classmethod @sqlalchemy.orm.validates("username") - def validate_username(cls, col, username): + def validate_username(self, col, username): if username is None: return username @@ -536,9 +535,9 @@ def validate_username(cls, col, username): if len(username) == 0: raise ValueError("Username cannot be empty") - if len(username) > cls.MAX_USERNAME_LENGTH: + if len(username) > self.MAX_USERNAME_LENGTH: raise ValueError( - f"Username can be at most {cls.MAX_USERNAME_LENGTH} characters" + f"Username can be at most {self.MAX_USERNAME_LENGTH} characters" ) return username diff --git a/zeeguu/core/test/test_user.py b/zeeguu/core/test/test_user.py index 667db31f..265f0382 100644 --- a/zeeguu/core/test/test_user.py +++ b/zeeguu/core/test/test_user.py @@ -73,11 +73,12 @@ def test_validate_name(self): def test_validate_username(self): username = "x" * 50 - assert User.validate_username("", username) == username + self.user.username = username + assert self.user.username == username def test_validate_username_too_long(self): with pytest.raises(ValueError, match="Username can be at most 50 characters"): - User.validate_username("", "x" * 51) + self.user.username = "x" * 51 def test_username_exists(self): assert User.username_exists(self.user.username) From f2d5f4372ec4316a47a2eaf51ba757eb51e00458 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Fri, 3 Apr 2026 21:41:42 +0200 Subject: [PATCH 37/43] Now also handling email already existing --- zeeguu/api/endpoints/user.py | 7 +++++-- zeeguu/core/account_management/user_account_creation.py | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 76be2700..785b64fa 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -239,6 +239,9 @@ def user_settings(): submitted_email = data.get("email", None) if submitted_email: + normalized_email = submitted_email.strip().lower() + if normalized_email != user.email.lower() and User.email_exists(normalized_email): + return make_error(400, "Email already in use") user.email = submitted_email submitted_password = data.get("password", None) @@ -262,10 +265,10 @@ def user_settings(): return "OK" except ValueError as e: zeeguu.core.model.db.session.rollback() - return bad_request(str(e)) + return make_error(400, str(e)) except sqlalchemy.exc.IntegrityError: zeeguu.core.model.db.session.rollback() - return bad_request("Could not update user settings") + return make_error(400, "Could not update user settings") @api.route("/send_feedback", methods=["POST"]) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 02fcbc82..5a742036 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -54,6 +54,9 @@ def create_account( if User.username_exists(normalized_username): raise Exception("Username already in use") + if User.email_exists(email): + raise Exception("There is already an account for this email.") + cohort = ( Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() if invite_code @@ -133,6 +136,9 @@ def create_basic_account( if User.username_exists(normalized_username): raise Exception("Username already in use") + if User.email_exists(email): + raise Exception("There is already an account for this email.") + cohort = ( Cohort.query.filter(func.lower(Cohort.inv_code) == invite_code.lower()).first() if invite_code From 6b8aa6f5db22100e79cc939e34e285b80c40866b Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Fri, 3 Apr 2026 21:49:50 +0200 Subject: [PATCH 38/43] Not handle name as username in create_basic_account --- zeeguu/core/account_management/user_account_creation.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 5a742036..2b35685e 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -132,9 +132,10 @@ def create_basic_account( if not valid_invite_code(invite_code): raise Exception("Invitation code is not recognized. Please contact us.") - normalized_username = _normalize_username(username) - if User.username_exists(normalized_username): - raise Exception("Username already in use") + # TODO: Implement this when username is implemented + # normalized_username = _normalize_username(username) + # if User.username_exists(normalized_username): + # raise Exception("Username already in use") if User.email_exists(email): raise Exception("There is already an account for this email.") @@ -151,13 +152,11 @@ def create_basic_account( raise Exception( "No more places in this class. Please contact us (zeeguu.team@gmail.com)." ) - try: new_user = User( email, username, password, - username=normalized_username, invitation_code=invite_code, creation_platform=creation_platform, ) From a8c3c7c9e092d923e61981983f4e0dbfcf38ad0f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Fri, 3 Apr 2026 21:51:14 +0200 Subject: [PATCH 39/43] UseUser.MAX_USERNAME_LENGTH --- zeeguu/core/test/test_user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeeguu/core/test/test_user.py b/zeeguu/core/test/test_user.py index 265f0382..a61110f9 100644 --- a/zeeguu/core/test/test_user.py +++ b/zeeguu/core/test/test_user.py @@ -72,13 +72,13 @@ def test_validate_name(self): assert User.validate_name("", random_name) def test_validate_username(self): - username = "x" * 50 + username = "x" * User.MAX_USERNAME_LENGTH self.user.username = username assert self.user.username == username def test_validate_username_too_long(self): - with pytest.raises(ValueError, match="Username can be at most 50 characters"): - self.user.username = "x" * 51 + with pytest.raises(ValueError, match=f"Username can be at most {User.MAX_USERNAME_LENGTH} characters"): + self.user.username = "x" * (User.MAX_USERNAME_LENGTH + 1) def test_username_exists(self): assert User.username_exists(self.user.username) From 91c52cc6381e6abbd5c1e57032fb10915b01a1b4 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 4 Apr 2026 13:07:09 +0200 Subject: [PATCH 40/43] Removed unnecessary except block --- zeeguu/core/account_management/user_account_creation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 2b35685e..3fc43a1d 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -115,8 +115,6 @@ def create_account( except sqlalchemy.exc.IntegrityError: raise Exception("There is already an account for this email.") - except ValueError: - raise except Exception as e: print(e) raise Exception("Could not create the account") From e962defad732dd069bef06d4ec8b802326a9525d Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 4 Apr 2026 13:10:42 +0200 Subject: [PATCH 41/43] Removed unnecessary except block --- zeeguu/core/account_management/user_account_creation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 3fc43a1d..a0dab4af 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -180,8 +180,6 @@ def create_basic_account( except sqlalchemy.exc.IntegrityError: raise Exception("There is already an account for this email.") - except ValueError: - raise except Exception as e: print(e) raise Exception("Could not create the account") From b1f00bc324defcd45d20d8285562feca82421d20 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 4 Apr 2026 14:17:14 +0200 Subject: [PATCH 42/43] Handled cases when find_by_username throws NoResultFound exception --- zeeguu/api/endpoints/badges.py | 14 +++++++++----- zeeguu/api/endpoints/daily_streak.py | 13 +++++++++++-- zeeguu/api/endpoints/friends.py | 28 +++++++++++++++++++++------- zeeguu/core/model/friend.py | 2 +- zeeguu/core/model/user.py | 7 +++++-- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 3fdda7f6..9cde992a 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -56,11 +56,15 @@ def get_badges_for_user(username: str = None): }, ... ] """ requester_id = flask.g.user_id - - used_user_id = User.find_by_username(username).id if username 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.") + if username is not None: + used_user = User.find_by_username(username) + if used_user is None: + return [] + used_user_id = used_user.id + if requester_id != used_user_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.") + else: + used_user_id = requester_id badges = Badge.query.options(joinedload(Badge.badge_levels)).all() user_badge_levels = UserBadgeLevel.find_all(used_user_id) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index 21b668d4..c49243da 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -28,7 +28,16 @@ def get_daily_streak(): @requires_session def get_all_daily_streak(username: str = None): requester_user_id = flask.g.user_id - requested_user_id = User.find_by_username(username).id if username is not None else requester_user_id + self_or_friend = True + if username is not None: + requested_user = User.find_by_username(username) + if requested_user is None: + return [] + requested_user_id = requested_user.id + if requester_user_id != requested_user_id and not Friend.are_friends(requester_user_id, requested_user_id): + self_or_friend = False + else: + requested_user_id = requester_user_id user = User.find_by_id(requested_user_id) user_languages = UserLanguage.all_user_languages_for_user(user) @@ -37,7 +46,7 @@ def get_all_daily_streak(username: str = None): obj = { "language": user_language.language.as_dictionary(), } - if requester_user_id == requested_user_id or Friend.are_friends(requester_user_id, requested_user_id): + if self_or_friend: obj.update({ "daily_streak": user_language.daily_streak or 0, "max_streak": user_language.max_streak or 0, diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index dbeb0b56..72651c52 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -25,10 +25,15 @@ def get_friends(username: str = 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.find_by_username(username).id if username 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.") + if username is not None: + used_user = User.find_by_username(username) + if used_user is None: + return [] + used_user_id = used_user.id + if requester_id != used_user_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.") + else: + used_user_id = requester_id friend_details = Friend.get_friends_with_details(used_user_id) result = [ @@ -116,7 +121,10 @@ def delete_friend_request(): """ sender_id = flask.g.user_id receiver_username = request.json.get("receiver_username") - receiver_id = User.find_by_username(receiver_username).id + receiver = User.find_by_username(receiver_username) + if receiver is None: + raise json_result({"success": False}) + receiver_id = receiver.id is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) return json_result({"success": is_deleted}) @@ -310,7 +318,10 @@ def get_sender_from_request(receiver_id:int, sender_field="sender_username"): sender_username = request.json.get(sender_field) if sender_username is None: raise ValueError("Missing sender username") - sender_id = User.find_by_username(sender_username).id + sender = User.find_by_username(sender_username) + if sender is None: + raise ValueError("Sender user not found") + sender_id = sender.id status_code, error_message = _validate_friend_request_participants(sender_id, receiver_id) if status_code >= 400: @@ -327,7 +338,10 @@ def get_receiver_from_request(sender_id:int, receiver_field="receiver_username") receiver_username = request.json.get(receiver_field) if receiver_username is None: raise ValueError("Missing receiver username") - receiver_id = User.find_by_username(receiver_username).id + receiver = User.find_by_username(receiver_username) + if receiver is None: + raise ValueError("Receiver user not found") + receiver_id = receiver.id status_code, error_message = _validate_friend_request_participants(sender_id, receiver_id) if status_code >= 400: diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 58f348e0..1eb710f8 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -185,7 +185,7 @@ def find_friend_details(cls, user_id: int, friend_username: str): from zeeguu.core.model.friend_request import FriendRequest friend = User.find_by_username(friend_username) - if not friend: + if friend is None: return None friend_user_id = friend.id diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 016e1574..ac35ecf2 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -1260,14 +1260,17 @@ def find_by_id(cls, id): @classmethod def find_by_username(cls, username): - return User.query.filter(User.username == username).one() + try: + return User.query.filter(User.username == username).one() + except NoResultFound: + return None @classmethod def username_exists(cls, username): try: cls.query.filter(cls.username == username).one() return True - except sqlalchemy.orm.exc.NoResultFound: + except NoResultFound: return False @classmethod From 6d4de2c6206133868570428f3963bf93faf3e474 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 4 Apr 2026 16:09:12 +0200 Subject: [PATCH 43/43] Fixed validation issues with /user_settings --- zeeguu/api/endpoints/user.py | 96 ++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 785b64fa..a1bb3b21 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -210,55 +210,55 @@ def user_settings(): """ :return: OK for success """ - user_id = flask.g.user_id - data = flask.request.form - 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: - normalized_username = submitted_username.strip() - if normalized_username != user.username and User.username_exists(normalized_username): - return make_error(400, "Username already in use") - 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) - - cefr_level = data.get("cefr_level", None) - submitted_learned_language_code = data.get("learned_language", None) - - if submitted_learned_language_code: - user.set_learned_language( - submitted_learned_language_code, cefr_level, zeeguu.core.model.db.session - ) - - submitted_email = data.get("email", None) - if submitted_email: - normalized_email = submitted_email.strip().lower() - if normalized_email != user.email.lower() and User.email_exists(normalized_email): - return make_error(400, "Email already in use") - user.email = submitted_email - - submitted_password = data.get("password", None) - 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) - - submitted_password = data.get("password", None) - if submitted_password: - user.update_password(submitted_password) - try: + user_id = flask.g.user_id + data = flask.request.form + 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: + normalized_username = submitted_username.strip() + if normalized_username != user.username and User.username_exists(normalized_username): + return make_error(400, "Username already in use") + 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) + + cefr_level = data.get("cefr_level", None) + submitted_learned_language_code = data.get("learned_language", None) + + if submitted_learned_language_code: + user.set_learned_language( + submitted_learned_language_code, cefr_level, zeeguu.core.model.db.session + ) + + submitted_email = data.get("email", None) + if submitted_email: + normalized_email = submitted_email.strip().lower() + if normalized_email != user.email.lower() and User.email_exists(normalized_email): + return make_error(400, "Email already in use") + user.email = submitted_email + + submitted_password = data.get("password", None) + 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) + + submitted_password = data.get("password", None) + if submitted_password: + user.update_password(submitted_password) + zeeguu.core.model.db.session.add(user_avatar) zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit()