diff --git a/tools/generate_fake_articles.py b/tools/generate_fake_articles.py new file mode 100644 index 00000000..dc9b99b1 --- /dev/null +++ b/tools/generate_fake_articles.py @@ -0,0 +1,197 @@ +#!/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 +from zeeguu.core.model.user import User +from zeeguu.core.model.user_article import UserArticle +from zeeguu.core.model.friend import Friend +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 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_articles = [] + 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_articles.append(article) + db.session.commit() + + 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() 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() 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/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 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/__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/badges.py b/zeeguu/api/endpoints/badges.py index 09ba6b95..9cde992a 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,10 +56,15 @@ 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 - - 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 c29d9763..c49243da 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -23,12 +23,21 @@ 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 + 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(user_id: int = 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 cba5020f..72651c52 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -6,75 +6,84 @@ 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.logging import log, warning +from zeeguu.core.model.user_avatar import UserAvatar +from zeeguu.logging import log from . import api # --------------------------------------------------------------------------- @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. + Get all friends for the current user, or for a friend by user_id. """ requester_id = flask.g.user_id - used_user_id = user_id if user_id is not None else requester_id - - if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id): - return make_error(403, "You can only view friends for yourself or your friends.") - - exclude_id = requester_id if used_user_id != requester_id else None - friends_with_friendships = Friend.get_friends_with_friendship(used_user_id, exclude_user_id=exclude_id) + 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 = [ - _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("/get_friend_requests", methods=["GET"]) +@api.route("/get_number_of_received_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_friend_requests(): +def get_number_of_received_friend_requests(): """ - Get all friend requests of a user + 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)) - friendRequest = FriendRequest.get_friend_requests_for_user(flask.g.user_id) - result = [_serialize_friend_request(req) for req in friendRequest] +# --------------------------------------------------------------------------- +@api.route("/get_received_friend_requests", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_received_friend_requests(): + """ + Get all friend requests received by a user. + """ + 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[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. """ - - 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_sent_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friend_requests] return json_result(result) @@ -85,17 +94,11 @@ 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) - 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: + 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) @@ -117,16 +120,16 @@ def delete_friend_request(): Delete a friend request between sender and receiver """ sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - log(f"delete_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(status_code, error) + receiver_username = request.json.get("receiver_username") + 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}) + # --------------------------------------------------------------------------- @api.route("/accept_friend_request", methods=["POST"]) # --------------------------------------------------------------------------- @@ -136,23 +139,22 @@ def accept_friend_request(): """ Accept a friend request between sender and receiver, and create a friendship """ - # current user is the receiver of the friend request - receiver_id = flask.g.user_id - sender_id = request.json.get("sender_id") - print(f"sender_id: {sender_id}") - status_code, error = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - log(f"accept_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") - return make_error(status_code, error) + try: + 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: 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"]) # --------------------------------------------------------------------------- @@ -162,18 +164,17 @@ def reject_friend_request(): """ Reject a friend request between sender and receiver, and delete the friend request record in the database """ - # current user is the receiver of the friend request - receiver_id = flask.g.user_id - sender_id = request.json.get("sender_id") - status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) - - if status_code >= 400: - log(f"reject_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") - return make_error(status_code, error_message) + try: + 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}) + # --------------------------------------------------------------------------- @api.route("/unfriend", methods=["POST"]) # --------------------------------------------------------------------------- @@ -181,17 +182,15 @@ 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 Friend row (friendship record) in the database. """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") + try: + 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)) - status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - log(f"unfriend: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") - return make_error(status_code, error_message) - - # Remove the friendship record from the database is_removed = Friend.remove_friendship(sender_id, receiver_id) log(f"unfriend: user_id={sender_id} unfriended user_id={receiver_id} - success={is_removed}") return json_result({"success": is_removed}) @@ -203,20 +202,26 @@ 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 matching the search term. """ - 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") + search_term = flask.request.args.get("query") + if not search_term or search_term.strip() == "": + return json_result([]) + + search_term = search_term.strip() + 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) @@ -224,73 +229,128 @@ def search_by_username(username): # Helper functions below # --------------------------------------------------------------------------- -def _serialize_friend_request(fr: FriendRequest): +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 + + +def _serialize_user(user: User): + return { + "name": user.name, + "username": user.username, + } + + +def _serialize_friendship(friendship: Friend, status: str = "accepted"): + if friendship is None: + return None + + return { + "sender_username": friendship.user.username, + "receiver_username": friendship.friend.username, + "created_at": friendship.created_at, + "friend_request_status": status, + "friend_streak": friendship.friend_streak, + "friend_streak_last_updated": friendship.friend_streak_last_updated.isoformat() if friendship.friend_streak_last_updated else None, + } + + +def _serialize_user_avatar(user_avatar: UserAvatar): + if user_avatar is None: + return None + + return { + "image_name": user_avatar.image_name, + "character_color": user_avatar.character_color, + "background_color": user_avatar.background_color, + } + + +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_friend_request(friend_request: 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 + friend_request (FriendRequest): The friend request object Returns: dict: JSON-serializable dictionary """ + if not friend_request: + return None + 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? + "name": friend_request.sender.name, + "username": friend_request.sender.username, }, "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? + "name": friend_request.receiver.name, + "username": friend_request.receiver.username, }, - "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, + "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_friendship(friendship: Friend, status: str = "accepted"): - return { - "id": friendship.id, - "sender_id": friendship.user_id, - "receiver_id": friendship.friend_id, - "created_at": friendship.created_at, - "friend_request_status": status, - "friend_streak": friendship.friend_streak, - "friend_streak_last_updated": friendship.friend_streak_last_updated.isoformat() if friendship.friend_streak_last_updated else None, - } - -def _serialize_user(user: User): - if user is None: - warning("_serialize_user: user is None") - return {} - - result = user.details_as_dictionary() or {} - if not isinstance(result, dict): - warning(f"_serialize_user: details_as_dictionary returned {type(result)} for user_id={user.id}") - result = {} - - result["id"] = user.id +def get_sender_from_request(receiver_id:int, sender_field="sender_username"): + """ + Extract sender_id from request.json and current session. + Returns: validated sender_id + Raises ValueError with message if validation fails. + """ + sender_username = request.json.get(sender_field) + if sender_username is None: + raise ValueError("Missing sender username") + 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: + raise ValueError(error_message) - return result + return sender_id -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 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 = 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: + raise ValueError(error_message) -def _serialize_users(users: list[User]): - return [_serialize_user(user) for user in users] + return receiver_id -def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: +def _validate_friend_request_participants(sender_id: int, receiver_id: int) -> 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 @@ -299,9 +359,9 @@ def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: :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" - + return 422, "invalid data sender or/and receiver" + 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" diff --git a/zeeguu/api/endpoints/leaderboards.py b/zeeguu/api/endpoints/leaderboards.py new file mode 100644 index 00000000..0ccdf431 --- /dev/null +++ b/zeeguu/api/endpoints/leaderboards.py @@ -0,0 +1,154 @@ +from datetime import datetime +from typing import Callable, Optional + +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, \ + friend_leaderboard_user_ids_subquery +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( + 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"], + ) + + 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/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index da9f1ef2..a1bb3b21 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 @@ -185,17 +187,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) @@ -208,74 +210,65 @@ 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: - 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: - 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) - 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 + 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) - - submitted_password = data.get("password", None) - if submitted_password: - user.update_password(submitted_password) - - zeeguu.core.model.db.session.add(user) - zeeguu.core.model.db.session.commit() - return "OK" + 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 make_error(400, str(e)) + except sqlalchemy.exc.IntegrityError: + zeeguu.core.model.db.session.rollback() + return make_error(400, "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..29d0ec31 100644 --- a/zeeguu/api/test/test_account_creation.py +++ b/zeeguu/api/test/test_account_creation.py @@ -17,16 +17,38 @@ 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"] +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/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." 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 diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index ccc2567f..a0dab4af 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,18 @@ 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") + + 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 + else None + ) if cohort: if cohort.cohort_still_has_capacity(): cohort_name = cohort.name @@ -56,13 +71,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 +92,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 +120,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 +130,19 @@ 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 + # 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.") + + 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 @@ -126,25 +150,26 @@ def create_basic_account(db_session, username, password, invite_code, email, cre raise Exception( "No more places in this class. Please contact us (zeeguu.team@gmail.com)." ) - try: new_user = User( - email, username, password, invitation_code=invite_code, creation_platform=creation_platform + email, + username, + password, + 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/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..a6c5dc62 --- /dev/null +++ b/zeeguu/core/leaderboards/leaderboards.py @@ -0,0 +1,258 @@ +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_ids_subquery, + 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 + + 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( + user_ids_subquery, + total_duration, + joins, + limit, + ) + + +def listening_time_leaderboard( + user_ids_subquery, + 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 + + 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( + user_ids_subquery, + total_duration, + joins, + limit, + ) + + +def read_articles_leaderboard( + user_ids_subquery, + 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 + + 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( + user_ids_subquery, + completed_articles_count, + joins, + limit, + ) + + +def reading_time_leaderboard( + user_ids_subquery, + 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 + + 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( + user_ids_subquery, + total_duration, + joins, + limit, + ) + + +def exercises_done_leaderboard( + user_ids_subquery, + 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 + ] + + 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( + user_ids_subquery, + 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 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, + 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 d0d3edc4..1eb710f8 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,11 +1,15 @@ -from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_ +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.db import db -from zeeguu.core.model.user import User # assuming you have a User model -from datetime import datetime, timedelta +from zeeguu.core.model.user import User +from zeeguu.core.model.user_avatar import UserAvatar + class Friend(db.Model): - __tablename__ = "friends" + __tablename__ = "friend" __table_args__ = {"mysql_collate": "utf8_bin"} id = Column(Integer, primary_key=True, autoincrement=True) @@ -14,8 +18,18 @@ class Friend(db.Model): 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. @@ -68,18 +82,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.""" @@ -88,52 +90,57 @@ 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() ) 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: @@ -149,9 +156,9 @@ def are_friends(cls, user1_id: int, user2_id: int) -> bool: ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) ).first() return friendship is not None - + @classmethod - def remove_friendship(cls, user1_id: int, user2_id: int)->bool: + 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)) | @@ -162,11 +169,11 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: db.session.delete(friendship) db.session.commit() return True - + 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 @@ -177,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) - if not friend: + friend = User.find_by_username(friend_username) + if friend is None: return None + friend_user_id = friend.id friendship = cls.query.filter( ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | @@ -192,7 +200,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 @@ -200,66 +208,76 @@ 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.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 - - query = User.query - query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) + + 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) + ) + + # Fetch all friendships involving the current user + friendships = Friend.query.filter( + (Friend.user_id == current_user_id) | (Friend.friend_id == current_user_id) + ).all() + + 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 in query.all(): - # Friendship status - friendship = Friend.query.filter( - ((Friend.user_id == current_user_id) & (Friend.friend_id == user.id)) | - ((Friend.user_id == user.id) & (Friend.friend_id == current_user_id)) - ).first() - - # Friend request status - friend_request = FriendRequest.query.filter( - ((FriendRequest.sender_id == current_user_id) & (FriendRequest.receiver_id == user.id)) | - ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) - ).order_by(FriendRequest.created_at.desc()).first() - - - friendship_or_friend_request = Friend._get_friendship_or_friendrequest( - friendship, - friend_request) - + + for user, avatar in query.all(): results.append({ - "user": { - "id": user.id, - "name": user.name, - "username": user.username, - "email": user.email, - "friendship": friendship_or_friend_request, - }, + "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 { - "id": friendship.id, "friend_streak": friendship.friend_streak, "friend_streak_last_updated": ( friendship.friend_streak_last_updated.isoformat() @@ -271,9 +289,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_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, @@ -283,11 +300,11 @@ def _get_friendship_or_friendrequest(friendship, friend_request): else None ), } - + + @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( @@ -303,4 +320,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 diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 8089da40..5c36667e 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()) @@ -90,37 +92,56 @@ 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_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 +149,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 +159,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: diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 51a26500..ac35ecf2 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,23 @@ def validate_name(cls, col, name): raise ValueError("Invalid username") return name + @sqlalchemy.orm.validates("username") + def validate_username(self, col, username): + if username is None: + return username + + username = username.strip() + + if len(username) == 0: + raise ValueError("Username cannot be empty") + + if len(username) > self.MAX_USERNAME_LENGTH: + raise ValueError( + f"Username can be at most {self.MAX_USERNAME_LENGTH} characters" + ) + + return username + def update_password(self, password: str): """ Update the user's password using bcrypt (secure, modern algorithm). @@ -1240,6 +1258,21 @@ 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): + 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 NoResultFound: + return False + @classmethod def all_recent_user_ids(cls, days=90): from zeeguu.core.model import UserActivityData 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) diff --git a/zeeguu/core/test/test_user.py b/zeeguu/core/test/test_user.py index 3202d5ab..a61110f9 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,19 @@ def test_validate_name(self): random_name = self.faker.name() assert User.validate_name("", random_name) + def test_validate_username(self): + 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=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) + 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()) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 1cf132cf..fd5e04e4 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -17,6 +17,7 @@ def _feature_map(): "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, "show_non_simplified_articles": _show_non_simplified_articles, + "gamification": _gamification } @@ -90,8 +91,41 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - for user_cohort in user.cohorts: if user_cohort.cohort_id in COHORTS_WITH_HIDDEN_RECOMMENDATIONS: return True return False + +# Gamification feature flag logic +from sqlalchemy.exc import NoResultFound + +from .model.user import User +from .model.cohort import Cohort +from datetime import datetime, date +GAMIFICATION_START_DATE = date(2026, 4, 1) +def _gamification(user: User): + """ + Enable general gamification features for users whose invitation with the gamification invite code, + or who are in the gamification cohort. This includes features like badges, friends, and leaderboards. + """ + + GAMIFICATION_INVITE_CODE = "CD8HGKKJ" + if user.is_dev: + return True + + # Invitation code can be None + invitation_code = user.invitation_code or "" + if invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + return True + + # Find gamification cohort by invite code, if it exists. + try: + gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + except NoResultFound: + gamification_cohort = None + + if gamification_cohort and user.is_member_of_cohort(gamification_cohort.id): + return True + + # Disabled for everyone else + return False