From 7b01e8e1366f8b2de2e12fc8f0c6d62ce79c7f6c Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 19 Feb 2026 13:27:17 +0100 Subject: [PATCH 001/142] Chaned python version in Dockerfile to version 3.11 to solve issue with dependencies --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 101ca1328..f053523be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12 +FROM python:3.11 # Install system packages (removed Apache) # Note: Removed apt-get upgrade to enable Docker layer caching @@ -24,13 +24,14 @@ COPY ./setup.py /Zeeguu-API/setup.py WORKDIR /Zeeguu-API +# RUN python -m pip install --upgrade pip setuptools # Install Python requirements with BuildKit cache mount # Cache persisted via buildkit-cache-dance action to GitHub Actions cache RUN --mount=type=cache,target=/root/.cache/pip \ echo "=== Pip cache before install ===" && \ ls -lah /root/.cache/pip 2>/dev/null || echo "Cache empty (first build)" && \ - python -m pip install -r requirements.txt && \ - python -m pip install gunicorn && \ + python -m pip install --default-timeout=300 -r requirements.txt && \ + python -m pip install --default-timeout=300 gunicorn && \ echo "=== Pip cache after install ===" && \ du -sh /root/.cache/pip From 901bdf6e0706a58ddb3fd1cf68ff379270154b4d Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 19 Feb 2026 13:53:46 +0100 Subject: [PATCH 002/142] Working on badge sql and model --- tools/migrations/26-02-19--add_badges.sql | 22 ++++++++++++++++++++++ zeeguu/core/model/badge.py | 19 +++++++++++++++++++ zeeguu/core/model/user_badge.py | 22 ++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 tools/migrations/26-02-19--add_badges.sql create mode 100644 zeeguu/core/model/badge.py create mode 100644 zeeguu/core/model/user_badge.py diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql new file mode 100644 index 000000000..fd33463b5 --- /dev/null +++ b/tools/migrations/26-02-19--add_badges.sql @@ -0,0 +1,22 @@ +-- tools/migrations/26-02-19--add_badge_and_user_badge_tables.sql + +CREATE TABLE badge ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + icon_url VARCHAR(255), + tier ENUM('bronze', 'silver', 'gold', 'platinum') NOT NULL, + is_hidden BOOLEAN DEFAULT FALSE, + is_unique BOOLEAN DEFAULT TRUE +); + +CREATE TABLE user_badge ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + badge_id INT NOT NULL, + achieved_at DATETIME DEFAULT CURRENT_TIMESTAMP, + shown_popup BOOLEAN DEFAULT FALSE, + UNIQUE(user_id, badge_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (badge_id) REFERENCES badge(id) +); \ No newline at end of file diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py new file mode 100644 index 000000000..24efdff77 --- /dev/null +++ b/zeeguu/core/model/badge.py @@ -0,0 +1,19 @@ +from zeeguu.core.model import db + +class Badge(db.Model): + __tablename__ = "badge" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + icon_url = db.Column(db.String(255)) + tier = db.Column( + db.Enum("bronze", "silver", "gold", "platinum", name="badge_tier"), + nullable=False + ) + is_hidden = db.Column(db.Boolean, default=False) + is_unique = db.Column(db.Boolean, default=True) + + def __repr__(self): + return f"" + diff --git a/zeeguu/core/model/user_badge.py b/zeeguu/core/model/user_badge.py new file mode 100644 index 000000000..df4b137b6 --- /dev/null +++ b/zeeguu/core/model/user_badge.py @@ -0,0 +1,22 @@ +from zeeguu.core.model import db + +class UserBadge(db.Model): + __tablename__ = "user_badge" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + achieved_at = db.Column(db.DateTime, server_default=db.func.now(), nullable=True) + shown_popup = db.Column(db.Boolean, default=False) + + # Relationships + user = db.relationship("User", backref="user_badges") + badge = db.relationship("Badge", backref="user_badges") + + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_id", name="uq_user_badge"), + ) + + def __repr__(self): + return f"" + From f66402bdb7c65c6ef3e568a643ac4dde48f95190 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 24 Feb 2026 12:19:48 +0100 Subject: [PATCH 003/142] Working on badges --- zeeguu/api/endpoints/__init__.py | 1 + zeeguu/api/endpoints/badges.py | 48 +++++++++++++++++++++++ zeeguu/core/model/badge.py | 65 ++++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 zeeguu/api/endpoints/badges.py diff --git a/zeeguu/api/endpoints/__init__.py b/zeeguu/api/endpoints/__init__.py index 2c9ebb382..65b9cea6a 100644 --- a/zeeguu/api/endpoints/__init__.py +++ b/zeeguu/api/endpoints/__init__.py @@ -48,3 +48,4 @@ from . import user_stats from . import session_history from . import daily_streak +from . import badges diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py new file mode 100644 index 000000000..7700cb536 --- /dev/null +++ b/zeeguu/api/endpoints/badges.py @@ -0,0 +1,48 @@ +import flask +from flask import request +from zeeguu.core.model.badge import Badge, BadgeLevel, UserBadgeLevel +from zeeguu.core.model.article_topic_user_feedback import ArticleTopicUserFeedback +from zeeguu.api.utils.json_result import json_result +from zeeguu.core.model.personal_copy import PersonalCopy +from sqlalchemy.orm.exc import NoResultFound +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from . import api, db_session +from zeeguu.core.model.article import HTML_TAG_CLEANR + +import re +from langdetect import detect +import json +from zeeguu.logging import log +# --------------------------------------------------------------------------- +@api.route("/badges/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +# @requires_session +def get_badges_for_user(user_id: int): + + # Get all badge levels achieved by the user + badges = Badge.query.all() + badge_levels = BadgeLevel.query.all() + user_badge_levels = UserBadgeLevel.query.filter_by(user_id=user_id).all() + achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} + result = [] + for badge in badges: + levels = [] + for level in badge.levels: # Assuming Badge has a .levels relationship + achieved = level.id in achieved_map + achieved_at = achieved_map[level.id].achieved_at if achieved else None + levels.append({ + "level": level.level, + "target_value": level.target_value, + "icon_url": level.icon_url, + "achieved": achieved, + "achieved_at": achieved_at.isoformat() if achieved_at else None, + }) + result.append({ + "badge_id": badge.id, + "name": badge.name, + "description": badge.description, + "levels": levels, + }) + return json_result(result) + diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 24efdff77..4d70daab8 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -1,4 +1,15 @@ -from zeeguu.core.model import db +from sqlalchemy import ( + Column, + Integer, + String, + ForeignKey, + DateTime, + UnicodeText, + desc, + Enum, + BigInteger, +) +from zeeguu.core.model.db import db class Badge(db.Model): __tablename__ = "badge" @@ -6,14 +17,54 @@ class Badge(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) + is_hidden = db.Column(db.Boolean, default=False) + + # Relationships + levels = db.relationship("BadgeLevel", back_populates="badge", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + +class BadgeLevel(db.Model): + __tablename__ = "badge_level" + + id = db.Column(db.Integer, primary_key=True) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + level = db.Column(db.Integer, nullable=False) + target_value = db.Column(db.Integer, nullable=False) icon_url = db.Column(db.String(255)) - tier = db.Column( - db.Enum("bronze", "silver", "gold", "platinum", name="badge_tier"), - nullable=False + + # Constraints + __table_args__ = ( + db.UniqueConstraint("badge_id", "level"), ) - is_hidden = db.Column(db.Boolean, default=False) - is_unique = db.Column(db.Boolean, default=True) + + # Relationships + badge = db.relationship("Badge", back_populates="levels") + user_badge_levels = db.relationship("UserBadgeLevel", back_populates="badge_level", cascade="all, delete-orphan") def __repr__(self): - return f"" + return f"" + +class UserBadgeLevel(db.Model): + __tablename__ = "user_badge_level" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) + achieved_at = db.Column(db.DateTime, default=None) + shown_popup = db.Column(db.Boolean, default=False) + # Constraints + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_level_id"), + ) + + # Relationships + badge_level = db.relationship("BadgeLevel", back_populates="user_badge_levels") + user = db.relationship("User") # Assuming User model exists + + def __repr__(self): + return f"" + + \ No newline at end of file From fe1c59e503f1d3e49eea788937dc741f15d02eca Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 24 Feb 2026 12:20:45 +0100 Subject: [PATCH 004/142] modified the migration script --- tools/migrations/26-02-19--add_badges.sql | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index fd33463b5..248a39f11 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -3,20 +3,27 @@ CREATE TABLE badge ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, - description TEXT, + description TEXT, -- We could store a template string, and would interpolate the target values. + is_hidden BOOLEAN DEFAULT FALSE +); + +CREATE TABLE badge_level ( + id INT AUTO_INCREMENT PRIMARY KEY, + badge_id INT NOT NULL, + level INT NOT NULL, + target_value INT NOT NULL, icon_url VARCHAR(255), - tier ENUM('bronze', 'silver', 'gold', 'platinum') NOT NULL, - is_hidden BOOLEAN DEFAULT FALSE, - is_unique BOOLEAN DEFAULT TRUE + UNIQUE(badge_id, level), + FOREIGN KEY (badge_id) REFERENCES badge(id) ); -CREATE TABLE user_badge ( +CREATE TABLE user_badge_level ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, - badge_id INT NOT NULL, - achieved_at DATETIME DEFAULT CURRENT_TIMESTAMP, + badge_level_id INT NOT NULL, + achieved_at DATETIME DEFAULT NULL, shown_popup BOOLEAN DEFAULT FALSE, - UNIQUE(user_id, badge_id), + UNIQUE(user_id, badge_level_id), FOREIGN KEY (user_id) REFERENCES user(id), - FOREIGN KEY (badge_id) REFERENCES badge(id) + FOREIGN KEY (badge_level_id) REFERENCES badge_level(id) ); \ No newline at end of file From 6b7528ab0fe0f0e010438548c20f1e8c17c0f5fb Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 24 Feb 2026 16:24:26 +0100 Subject: [PATCH 005/142] working on badge progress endpoint --- zeeguu/api/endpoints/badges.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 7700cb536..fb1bc3413 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -46,3 +46,30 @@ def get_badges_for_user(user_id: int): }) return json_result(result) + +## Update badge progress endpoint (not implemented yet) +# --------------------------------------------------------------------------- +@api.route("/update_badge_progress", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +# @requires_session +def update_badge_progress(): + + # For badge id + badge_id = request.form.get("badge_id") + user_id = request.form.get("user_id") + + # Validation of inputs + if not badge_id or not user_id: + return json_result({"error": "Missing badge_id or user_id"}, status=400) + + + + # Get current progress for the badge and user + user_badge_level = UserBadgeLevel.query.filter_by(badge_id=badge_id, user_id=user_id).first() + if not user_badge_level: + return json_result({"error": "User badge level not found"}, status=404) + + + + From b350f6e1b023286ad5ecf97dd73928bb6a528527 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 24 Feb 2026 16:30:54 +0100 Subject: [PATCH 006/142] Working on friendshipsystem --- .../migrations/26-02-24-friendship_system.sql | 21 +++++++++++++++++++ zeeguu/core/model/friend.py | 0 2 files changed, 21 insertions(+) create mode 100644 tools/migrations/26-02-24-friendship_system.sql create mode 100644 zeeguu/core/model/friend.py diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql new file mode 100644 index 000000000..09c85bd2d --- /dev/null +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -0,0 +1,21 @@ +-- Denormalized friends table +CREATE TABLE friends ( + user_id INT NOT NULL, + friend_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, friend_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (friend_id) REFERENCES users(id) +); + +-- Friend requests table +CREATE TABLE friend_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + responded_at DATETIME, + FOREIGN KEY (sender_id) REFERENCES users(id), + FOREIGN KEY (receiver_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py new file mode 100644 index 000000000..e69de29bb From 6f38346d2271758f02c2eb55be124ce8534b2e6a Mon Sep 17 00:00:00 2001 From: gabortodor Date: Tue, 24 Feb 2026 19:40:00 +0100 Subject: [PATCH 007/142] Started generic implementation for badge level checking --- zeeguu/api/endpoints/badges.py | 35 +++++++------ zeeguu/core/model/badge.py | 38 -------------- zeeguu/core/model/badge_level.py | 30 +++++++++++ zeeguu/core/model/user_badge.py | 22 -------- zeeguu/core/model/user_badge_level.py | 72 +++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 78 deletions(-) create mode 100644 zeeguu/core/model/badge_level.py delete mode 100644 zeeguu/core/model/user_badge.py create mode 100644 zeeguu/core/model/user_badge_level.py diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index fb1bc3413..c266e09e8 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,29 +1,22 @@ import flask from flask import request -from zeeguu.core.model.badge import Badge, BadgeLevel, UserBadgeLevel -from zeeguu.core.model.article_topic_user_feedback import ArticleTopicUserFeedback +from zeeguu.core.model.badge import Badge +from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.core.model.user_badge_level import UserBadgeLevel from zeeguu.api.utils.json_result import json_result -from zeeguu.core.model.personal_copy import PersonalCopy -from sqlalchemy.orm.exc import NoResultFound from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api, db_session -from zeeguu.core.model.article import HTML_TAG_CLEANR -import re -from langdetect import detect -import json -from zeeguu.logging import log + # --------------------------------------------------------------------------- @api.route("/badges/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain # @requires_session def get_badges_for_user(user_id: int): - # Get all badge levels achieved by the user badges = Badge.query.all() - badge_levels = BadgeLevel.query.all() - user_badge_levels = UserBadgeLevel.query.filter_by(user_id=user_id).all() + user_badge_levels = UserBadgeLevel.find(user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} result = [] for badge in badges: @@ -54,22 +47,28 @@ def get_badges_for_user(user_id: int): @cross_domain # @requires_session def update_badge_progress(): - - # For badge id + # For badge id badge_id = request.form.get("badge_id") user_id = request.form.get("user_id") # Validation of inputs if not badge_id or not user_id: return json_result({"error": "Missing badge_id or user_id"}, status=400) - - # Get current progress for the badge and user user_badge_level = UserBadgeLevel.query.filter_by(badge_id=badge_id, user_id=user_id).first() if not user_badge_level: return json_result({"error": "User badge level not found"}, status=404) - - +def check_badge_level(badge_id: int, user_id: int, current_value: int) -> UserBadgeLevel: + user_badge_level = UserBadgeLevel.find(user_id=user_id, badge_id=badge_id) + if not user_badge_level: + next_badge_level = BadgeLevel.find(badge_id=badge_id, level=1) + else: + next_badge_level = BadgeLevel.find(badge_id=badge_id, level=user_badge_level.badge_level.level + 1) + if not next_badge_level: + return None + if current_value >= next_badge_level.target_value: + return UserBadgeLevel.create(user_id=user_id, badge_level_id=next_badge_level.id) + return user_badge_level diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 4d70daab8..1f615a170 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -25,46 +25,8 @@ class Badge(db.Model): def __repr__(self): return f"" -class BadgeLevel(db.Model): - __tablename__ = "badge_level" - id = db.Column(db.Integer, primary_key=True) - badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) - level = db.Column(db.Integer, nullable=False) - target_value = db.Column(db.Integer, nullable=False) - icon_url = db.Column(db.String(255)) - - # Constraints - __table_args__ = ( - db.UniqueConstraint("badge_id", "level"), - ) - - # Relationships - badge = db.relationship("Badge", back_populates="levels") - user_badge_levels = db.relationship("UserBadgeLevel", back_populates="badge_level", cascade="all, delete-orphan") - - def __repr__(self): - return f"" -class UserBadgeLevel(db.Model): - __tablename__ = "user_badge_level" - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) - achieved_at = db.Column(db.DateTime, default=None) - shown_popup = db.Column(db.Boolean, default=False) - - # Constraints - __table_args__ = ( - db.UniqueConstraint("user_id", "badge_level_id"), - ) - - # Relationships - badge_level = db.relationship("BadgeLevel", back_populates="user_badge_levels") - user = db.relationship("User") # Assuming User model exists - - def __repr__(self): - return f"" \ No newline at end of file diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py new file mode 100644 index 000000000..45d9cf882 --- /dev/null +++ b/zeeguu/core/model/badge_level.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from zeeguu.core.model.db import db + +class BadgeLevel(db.Model): + __tablename__ = "badge_level" + + id = db.Column(db.Integer, primary_key=True) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + level = db.Column(db.Integer, nullable=False) + target_value = db.Column(db.Integer, nullable=False) + icon_url = db.Column(db.String(255)) + + # Constraints + __table_args__ = ( + db.UniqueConstraint("badge_id", "level"), + ) + + # Relationships + badge = db.relationship("Badge", back_populates="levels") + user_badge_levels = db.relationship("UserBadgeLevel", back_populates="badge_level", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + @classmethod + def find(cls, badge_id: int, level: int): + """Find badge level for a specific badge id and level""" + return cls.query.filter_by(badge_id=badge_id, level = level).first() \ No newline at end of file diff --git a/zeeguu/core/model/user_badge.py b/zeeguu/core/model/user_badge.py deleted file mode 100644 index df4b137b6..000000000 --- a/zeeguu/core/model/user_badge.py +++ /dev/null @@ -1,22 +0,0 @@ -from zeeguu.core.model import db - -class UserBadge(db.Model): - __tablename__ = "user_badge" - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) - achieved_at = db.Column(db.DateTime, server_default=db.func.now(), nullable=True) - shown_popup = db.Column(db.Boolean, default=False) - - # Relationships - user = db.relationship("User", backref="user_badges") - badge = db.relationship("Badge", backref="user_badges") - - __table_args__ = ( - db.UniqueConstraint("user_id", "badge_id", name="uq_user_badge"), - ) - - def __repr__(self): - return f"" - diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py new file mode 100644 index 000000000..2dd2ae23d --- /dev/null +++ b/zeeguu/core/model/user_badge_level.py @@ -0,0 +1,72 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship +import sqlalchemy +from datetime import datetime + +from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.core.model.db import db +from zeeguu.core.model.user import User + + +class UserBadgeLevel(db.Model): + __tablename__ = "user_badge_level" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) + achieved_at = db.Column(db.DateTime, default=None) + shown_popup = db.Column(db.Boolean, default=False) # Maybe a more generic name? + + # Constraints + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_level_id"), + ) + + # Relationships + badge_level = db.relationship("BadgeLevel", back_populates="user_badge_levels") + user = db.relationship("User") + + def __init__( + self, + user_id, + badge_level_id, + achieved_at=None, + shown_popup=False + ): + self.user_id = user_id + self.badge_level_id = badge_level_id + self.shown_popup = shown_popup + if achieved_at is None: + self.achieved_at = datetime.now() + else: + self.achieved_at = achieved_at + + + def __repr__(self): + return f"" + + @classmethod + def find(cls, user_id: int): + """Find existing user badge levels by user id.""" + try: + return cls.query.filter_by(user_id=user_id).all() + except sqlalchemy.orm.exc.NoResultFound: + return None + + @classmethod + def find(cls, user_id: int, badge_id: int): + """Find user badge level bz user_id and badge id.""" + return cls.query.filter_by(user_id=user_id, badge_id=badge_id).first() + + @classmethod + def create( + cls, + session, + user_id: int, + badge_level_id: int, + achieved_at: datetime = None, + shown_popup: bool = False, + ): + new = cls(user_id, badge_level_id, achieved_at, shown_popup) + session.add(new) + return new From 864e4bea92e41f4b3da34b2d92857ff11200b7f4 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Wed, 25 Feb 2026 09:55:27 +0100 Subject: [PATCH 008/142] Fixed method overload issue with badge levels --- zeeguu/api/endpoints/badges.py | 2 +- zeeguu/core/model/user_badge_level.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index c266e09e8..26c3a2c27 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -16,7 +16,7 @@ def get_badges_for_user(user_id: int): # Get all badge levels achieved by the user badges = Badge.query.all() - user_badge_levels = UserBadgeLevel.find(user_id) + user_badge_levels = UserBadgeLevel.find_all(user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} result = [] for badge in badges: diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index 2dd2ae23d..0e248950c 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -1,11 +1,9 @@ -from sqlalchemy import Column, Integer, String, ForeignKey -from sqlalchemy.orm import relationship -import sqlalchemy from datetime import datetime -from zeeguu.core.model.badge_level import BadgeLevel +import sqlalchemy +from sqlalchemy.orm import relationship + from zeeguu.core.model.db import db -from zeeguu.core.model.user import User class UserBadgeLevel(db.Model): @@ -46,7 +44,7 @@ def __repr__(self): return f"" @classmethod - def find(cls, user_id: int): + def find_all(cls, user_id: int): """Find existing user badge levels by user id.""" try: return cls.query.filter_by(user_id=user_id).all() From dc35879ad8537e58dff2385f1c8a21e380606545 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 25 Feb 2026 16:54:54 +0100 Subject: [PATCH 009/142] Added 'update_badge_levels' method and minor schema changes --- tools/migrations/26-02-19--add_badges.sql | 4 +- zeeguu/api/endpoints/badges.py | 48 +++++++++++++++++------ zeeguu/api/endpoints/translation.py | 4 ++ zeeguu/core/model/badge.py | 33 ++++++++-------- zeeguu/core/model/badge_level.py | 14 +++++-- zeeguu/core/model/user_badge_level.py | 26 +++++++----- 6 files changed, 87 insertions(+), 42 deletions(-) diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index 248a39f11..8493bd17f 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -2,9 +2,11 @@ CREATE TABLE badge ( id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, -- We could store a template string, and would interpolate the target values. is_hidden BOOLEAN DEFAULT FALSE + UNIQUE(code) ); CREATE TABLE badge_level ( @@ -22,7 +24,7 @@ CREATE TABLE user_badge_level ( user_id INT NOT NULL, badge_level_id INT NOT NULL, achieved_at DATETIME DEFAULT NULL, - shown_popup BOOLEAN DEFAULT FALSE, + is_shown BOOLEAN DEFAULT FALSE, UNIQUE(user_id, badge_level_id), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (badge_level_id) REFERENCES badge_level(id) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 26c3a2c27..9073aaf08 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,5 +1,6 @@ -import flask from flask import request + +from zeeguu.core.model.badge import BadgeCode from zeeguu.core.model.badge import Badge from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.user_badge_level import UserBadgeLevel @@ -30,6 +31,7 @@ def get_badges_for_user(user_id: int): "icon_url": level.icon_url, "achieved": achieved, "achieved_at": achieved_at.isoformat() if achieved_at else None, + "is_shown": level.is_shown }) result.append({ "badge_id": badge.id, @@ -61,14 +63,36 @@ def update_badge_progress(): return json_result({"error": "User badge level not found"}, status=404) -def check_badge_level(badge_id: int, user_id: int, current_value: int) -> UserBadgeLevel: - user_badge_level = UserBadgeLevel.find(user_id=user_id, badge_id=badge_id) - if not user_badge_level: - next_badge_level = BadgeLevel.find(badge_id=badge_id, level=1) - else: - next_badge_level = BadgeLevel.find(badge_id=badge_id, level=user_badge_level.badge_level.level + 1) - if not next_badge_level: - return None - if current_value >= next_badge_level.target_value: - return UserBadgeLevel.create(user_id=user_id, badge_level_id=next_badge_level.id) - return user_badge_level +def update_badge_levels(badge_code: BadgeCode, user_id: int, current_value: int) -> list[UserBadgeLevel]: + """ + Award all achievable badge levels a user doesn't have yet for a specific badge. + + Returns only newly created UserBadgeLevel objects. + """ + badge = Badge.find(badge_code) + if not badge: + return [] + + badge_level_ids = [ + level.id + for level in BadgeLevel.find_all_achievable(badge_id=badge.id, current_value=current_value) + ] + + if not badge_level_ids: + return [] + + user_badge_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) + owned_ids = {lvl.badge_level_id for lvl in user_badge_levels} + + missing_ids = set(badge_level_ids) - owned_ids + created_badges: list[UserBadgeLevel] = [] + + for level_id in missing_ids: + new_badge = UserBadgeLevel(user_id=user_id, badge_level_id=level_id) + db_session.add(new_badge) + created_badges.append(new_badge) + + if missing_ids: + db_session.commit() + + return created_badges diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 5e1b6c126..5df941f8b 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -6,6 +6,7 @@ from flask import request from python_translators.translation_query import TranslationQuery +from zeeguu.core.model.badge import BadgeCode from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean from zeeguu.api.utils.route_wrappers import cross_domain, requires_session @@ -25,6 +26,7 @@ from zeeguu.core.model.text import Text from . import api, db_session from zeeguu.logging import log as zeeguu_log +from zeeguu.api.endpoints.badges import update_badge_levels punctuation_extended = "»«" + punctuation IS_DEV_SKIP_TRANSLATION = int(os.environ.get("DEV_SKIP_TRANSLATION", 0)) == 1 @@ -157,6 +159,8 @@ def get_one_translation(from_lang_code, to_lang_code): log( f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) + current_bookmark_count = len(Bookmark.find_by_specific_user(user)) + update_badge_levels(badge_code=BadgeCode.MEANING_BUILDER, user_id=user.id, current_value=current_bookmark_count) return json_result( { diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 1f615a170..7be417998 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -1,32 +1,33 @@ -from sqlalchemy import ( - Column, - Integer, - String, - ForeignKey, - DateTime, - UnicodeText, - desc, - Enum, - BigInteger, -) +import enum + from zeeguu.core.model.db import db + +class BadgeCode(enum.Enum): + MEANING_BUILDER = 'MEANING_BUILDER' + + class Badge(db.Model): __tablename__ = "badge" id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) is_hidden = db.Column(db.Boolean, default=False) + # Constraints + __table_args__ = ( + db.UniqueConstraint("code"), + ) + # Relationships levels = db.relationship("BadgeLevel", back_populates="badge", cascade="all, delete-orphan") def __repr__(self): return f"" - - - - - \ No newline at end of file + @classmethod + def find(cls, code: BadgeCode): + """Find badge for a specific code""" + return cls.query.filter_by(code=code.value).first() diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 45d9cf882..978d6756a 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -1,8 +1,6 @@ -from sqlalchemy import Column, Integer, String, ForeignKey -from sqlalchemy.orm import relationship - from zeeguu.core.model.db import db + class BadgeLevel(db.Model): __tablename__ = "badge_level" @@ -24,7 +22,15 @@ class BadgeLevel(db.Model): def __repr__(self): return f"" + @classmethod + def find_all_achievable(cls, badge_id: int, current_value: int): + """Find all badge levels for a specific badge id that are achievable""" + return cls.query.filter( + cls.badge_id == badge_id, + cls.target_value <= current_value + ).all() + @classmethod def find(cls, badge_id: int, level: int): """Find badge level for a specific badge id and level""" - return cls.query.filter_by(badge_id=badge_id, level = level).first() \ No newline at end of file + return cls.query.filter_by(badge_id=badge_id, level=level).first() diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index 0e248950c..867509e26 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -13,7 +13,7 @@ class UserBadgeLevel(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) achieved_at = db.Column(db.DateTime, default=None) - shown_popup = db.Column(db.Boolean, default=False) # Maybe a more generic name? + is_shown = db.Column(db.Boolean, default=False) # Constraints __table_args__ = ( @@ -29,17 +29,16 @@ def __init__( user_id, badge_level_id, achieved_at=None, - shown_popup=False + is_shown=False ): self.user_id = user_id self.badge_level_id = badge_level_id - self.shown_popup = shown_popup + self.is_shown = is_shown if achieved_at is None: self.achieved_at = datetime.now() else: self.achieved_at = achieved_at - def __repr__(self): return f"" @@ -52,9 +51,17 @@ def find_all(cls, user_id: int): return None @classmethod - def find(cls, user_id: int, badge_id: int): - """Find user badge level bz user_id and badge id.""" - return cls.query.filter_by(user_id=user_id, badge_id=badge_id).first() + def find_all_not_shown(cls, user_id: int): + """Find existing not shown user badge levels by user id.""" + try: + return cls.query.filter_by(user_id=user_id, is_shown=False).all() + except sqlalchemy.orm.exc.NoResultFound: + return None + + @classmethod + def find(cls, user_id: int, badge_level_ids: list[int]): + """Find user badge levels for a specific user_id and badge_level_id.""" + return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() @classmethod def create( @@ -63,8 +70,9 @@ def create( user_id: int, badge_level_id: int, achieved_at: datetime = None, - shown_popup: bool = False, + is_shown: bool = False, ): - new = cls(user_id, badge_level_id, achieved_at, shown_popup) + new = cls(user_id, badge_level_id, achieved_at, is_shown) session.add(new) + session.commit() return new From 950d0a7806aa8f12b67948b7089351c5911c46d7 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 25 Feb 2026 16:58:17 +0100 Subject: [PATCH 010/142] Added name field to badge level --- tools/migrations/26-02-19--add_badges.sql | 1 + zeeguu/core/model/badge_level.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index 8493bd17f..0ca58ab35 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -12,6 +12,7 @@ CREATE TABLE badge ( CREATE TABLE badge_level ( id INT AUTO_INCREMENT PRIMARY KEY, badge_id INT NOT NULL, + name VARCHAR(100), level INT NOT NULL, target_value INT NOT NULL, icon_url VARCHAR(255), diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 978d6756a..500a8d2bd 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -5,6 +5,7 @@ class BadgeLevel(db.Model): __tablename__ = "badge_level" id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) level = db.Column(db.Integer, nullable=False) target_value = db.Column(db.Integer, nullable=False) From 2bcb35290f602d3dfde765ee81644249989bbdb0 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 26 Feb 2026 11:03:42 +0100 Subject: [PATCH 011/142] Reverted Python version to 3.12 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f053523be..022567b1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11 +FROM python:3.12 # Install system packages (removed Apache) # Note: Removed apt-get upgrade to enable Docker layer caching From aa51cf680480e137e9474698fe096f9f70ee1607 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Feb 2026 13:12:11 +0100 Subject: [PATCH 012/142] Working on friends and friend requests --- zeeguu/api/endpoints/__init__.py | 1 + zeeguu/api/endpoints/friends.py | 89 ++++++++++++++++++++++ zeeguu/core/model/friend.py | 42 +++++++++++ zeeguu/core/model/friend_request.py | 110 ++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 zeeguu/api/endpoints/friends.py create mode 100644 zeeguu/core/model/friend_request.py diff --git a/zeeguu/api/endpoints/__init__.py b/zeeguu/api/endpoints/__init__.py index 2c9ebb382..56323943e 100644 --- a/zeeguu/api/endpoints/__init__.py +++ b/zeeguu/api/endpoints/__init__.py @@ -48,3 +48,4 @@ from . import user_stats from . import session_history from . import daily_streak +from . import friends diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py new file mode 100644 index 000000000..1ccc9626c --- /dev/null +++ b/zeeguu/api/endpoints/friends.py @@ -0,0 +1,89 @@ +import flask +from flask import request +from zeeguu.core.model import Article, Language, User, Topic, UserArticle, UserArticleBrokenReport +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.friend_request import FriendRequest +from zeeguu.core.model.article_topic_user_feedback import ArticleTopicUserFeedback +from zeeguu.api.utils.json_result import json_result +from zeeguu.core.model.personal_copy import PersonalCopy +from sqlalchemy.orm.exc import NoResultFound +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from . import api, db_session +from zeeguu.core.model.article import HTML_TAG_CLEANR + +# import re +# from langdetect import detect +# import json +# from zeeguu.logging import log + + + +# --------------------------------------------------------------------------- +@api.route("/get_friends/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +# @requires_session +def get_friends(user_id): + """ + Get all friends of a user + """ + friends = Friend.get_friends(user_id) + for friend in friends: + print(friend.email) + return "ok" + + + +# --------------------------------------------------------------------------- +@api.route("/get_friend_requests/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +# @requires_session +def get_friend_requests(user_id): + friendRequest : list[FriendRequest] = FriendRequest.get_friend_requests_for_user(user_id) + + return [_serialize_friend_request(req) for req in friendRequest] + +def _serialize_friend_request(fr: FriendRequest): + """ + Serialize a FriendRequest object into JSON-friendly dict. + + Args: + fr (FriendRequest): The friend request object + current_user_id (int): Optional, to simplify sender/receiver info + + Returns: + dict: JSON-serializable dictionary + """ + return { + "id": fr.id, + "sender": { + "id": fr.sender.id, # This is the user_id is that nesessary? + "name": fr.sender.name, # This will be updated to username + "email": fr.sender.email, # Is this relevant? + }, + "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, + } + +# --------------------------------------------------------------------------- +@api.route("/send_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +# @requires_session +def send_friend_request(): + + FriendRequest.send_friend_request() + +def delete_friend_reuest(): + pass + + +def accept_friend_request(): + pass + +def search_by_username(): + pass + + diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index e69de29bb..7e0f4c247 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func, or_ +from sqlalchemy.orm import relationship +from zeeguu.core.model.db import db +from zeeguu.core.model.user import User # assuming you have a User model + + +class Friend(db.Model): + __tablename__ = "friends" + __table_args__ = {"mysql_collate": "utf8_bin"} + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + friend_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + created_at = Column(DateTime, default=func.now()) + + # Explicit relationships with primaryjoin + user = relationship( + User, + foreign_keys=[user_id], + primaryjoin="Friend.user_id == User.id" + ) + friend = relationship( + User, + foreign_keys=[friend_id], + primaryjoin="Friend.friend_id == User.id" + ) + + + @staticmethod + def get_friends(user_id): + """Return a list of User objects that are friends with the given user_id.""" + # query where user is either the user_id or the friend_id + friends = ( + db.session.query(User) + .join(Friend, or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + .filter( + (Friend.user_id == user_id) & (User.id == Friend.friend_id) + | (Friend.friend_id == user_id) & (User.id == Friend.user_id) + ) + .all() + ) + return friends + \ No newline at end of file diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py new file mode 100644 index 000000000..3ff148cd9 --- /dev/null +++ b/zeeguu/core/model/friend_request.py @@ -0,0 +1,110 @@ +from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func +from sqlalchemy.orm import relationship +from zeeguu.core.model.db import db +from zeeguu.core.model.user import User # assuming you have a User model + +class FriendRequest(db.Model): + __tablename__ = "friend_requests" + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True, autoincrement=True) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column( + Enum("pending", "accepted", "rejected", name="friend_request_status"), + default="pending", + ) + created_at = Column(DateTime, default=func.now()) + responded_at = Column(DateTime, nullable=True) + + # relationships + # Relationships — explicit foreign_keys and primaryjoin + sender = relationship( + User, + foreign_keys=[sender_id], + primaryjoin="FriendRequest.sender_id == User.id" + ) + receiver = relationship( + User, + foreign_keys=[receiver_id], + primaryjoin="FriendRequest.receiver_id == User.id" + ) + + def __init__( + self, + sender_id, + receiver_id, + status="pending" + ): + self.sender_id = sender_id + self.receiver_id = receiver_id + self.status = status + + + def __repr__(self): + return "id: "+ str(self.id) + "sender: "+ str(self.sender_id) + "reciever: " + str( self.receiver_id) + + + def send_friend_request(sender_id: int, receiver_id: int): + """ + Send a friend request from sender to receiver. + + Args: + sender_id (int): ID of the user sending the request + receiver_id (int): ID of the user receiving the request + + Returns: + FriendRequest: The created friend request object + """ + + # Prevent sending request to self + if sender_id == receiver_id: + raise ValueError("Cannot send a friend request to yourself.") + + # Check if users exist + sender = db.session.query(User).filter_by(id=sender_id).first() + receiver = db.session.query(User).filter_by(id=receiver_id).first() + if not sender or not receiver: + raise ValueError("Sender or receiver does not exist.") + + # Check for existing friend request + existing_request = db.session.query(FriendRequest).filter( + ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | + ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) + ).first() + + if existing_request: + raise ValueError("A friend request already exists between these users.") + + # Create new friend request + new_request = FriendRequest( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" ## TODO: This should be an enum/constant + ) + + db.session.add(new_request) + db.session.commit() + db.session.refresh(new_request) # To get the ID and timestamps + + return new_request + def get_friend_requests_for_user(user_id: int, status: str = "pending"): + """ + Get friend requests received by a user. + + Args: + session (Session): SQLAlchemy session + user_id (int): ID of the user + status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" + + Returns: + List[FriendRequest]: List of friend request objects + """ + requests = ( + db.session.query(FriendRequest) + .filter(FriendRequest.receiver_id == user_id) + .filter(FriendRequest.status == status) + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests \ No newline at end of file From fcb8271c1cddb46408c7e58e247ab307ea5ab1fe Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Feb 2026 14:59:44 +0100 Subject: [PATCH 013/142] Working on the different friends features, sending, cancel friend reqeusts --- zeeguu/api/endpoints/friends.py | 87 ++++++++++++++++++++++++++++- zeeguu/core/model/friend.py | 38 ++++++++++++- zeeguu/core/model/friend_request.py | 71 ++++++++++++++++++++--- 3 files changed, 184 insertions(+), 12 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 1ccc9626c..81cbd85d4 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -73,15 +73,96 @@ def _serialize_friend_request(fr: FriendRequest): @cross_domain # @requires_session def send_friend_request(): + sender_id = request.form.get("sender_id", type=int) + receiver_id = request.form.get("receiver_id", type=int) + + if sender_id is None or receiver_id is None: + return "error" - FriendRequest.send_friend_request() + if sender_id == receiver_id: + return "error" # TODO: Handle error + + friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) + return _seralize_friend_request(friend_request) +def _seralize_friend_request(friend_request: FriendRequest): + return { + "id": friend_request.id, + "sender_id": friend_request.sender_id, + "receiver_id": friend_request.receiver_id, + "created_at": friend_request.created_at, + "reponded_at": friend_request.responded_at, + "status": friend_request.status, + } + +def _is_friend_request_valid(sender_id, receiver_id)-> tuple[bool, str]: + if sender_id is None or receiver_id is None: + return False, "invalid data sender_id or/and receiver_id" + + if sender_id == receiver_id: + return False, "cannot send friend request to yourself" + + return True, "ok" + +@api.route("/delete_friend_request", methods=["POST"]) +@cross_domain def delete_friend_reuest(): - pass + sender_id = request.form.get("sender_id", type=int) + receiver_id = request.form.get("receiver_id", type=int) + + is_valid, error = _is_friend_request_valid(sender_id, receiver_id) + if not is_valid: + return error + + is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) + return str(is_deleted) + +@api.route("/accept_friend_request", methods=["POST"]) +@cross_domain def accept_friend_request(): - pass + sender_id = request.form.get("sender_id", type=int) + receiver_id = request.form.get("receiver_id", type=int) + is_valid, error = _is_friend_request_valid(sender_id, receiver_id) + if not is_valid: + return error + + friend_request = FriendRequest.accept_friend_request(sender_id, receiver_id) + if friend_request is None: + return "None" + return _seralize_friend_request(friend_request) + + +@api.route("/cancel_friend_request", methods=["POST"]) +@cross_domain +def accept_friend_request(): + """ + Cancel the send friend request from the point of view of the sender. + """ + sender_id = request.form.get("sender_id", type=int) + receiver_id = request.form.get("receiver_id", type=int) + is_valid, error = _is_friend_request_valid(sender_id, receiver_id) + if not is_valid: + return error + + is_canceled = FriendRequest.cancel_sent_request(sender_id, receiver_id) + return str(is_canceled) + +@api.route("/unfriend", methods=["POST"]) +@cross_domain +def accept_friend_request(): + """ + Cancel the send friend request from the point of view of the sender. + """ + sender_id = request.form.get("sender_id", type=int) + receiver_id = request.form.get("receiver_id", type=int) + is_valid, error = _is_friend_request_valid(sender_id, receiver_id) + if not is_valid: + return error + is_removed = Friend.remove_friendship(sender_id, receiver_id) + return str(is_removed) + def search_by_username(): pass diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 7e0f4c247..477786916 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -8,6 +8,7 @@ class Friend(db.Model): __tablename__ = "friends" __table_args__ = {"mysql_collate": "utf8_bin"} + id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) friend_id = Column(Integer, ForeignKey("users.id"), primary_key=True) created_at = Column(DateTime, default=func.now()) @@ -39,4 +40,39 @@ def get_friends(user_id): .all() ) return friends - \ No newline at end of file + + @classmethod + def remove_friendship(cls, user1_id: int, user2_id: int)->bool: + # Look for friendship in either direction + friendship = cls.query.filter( + ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | + ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) + ).first() + + if friendship: + db.session.delete(friendship) + db.session.commit() + return True + + return False + + def add_friendship(user_id: int, friend_id: int)->bool: + """ + Adds a friendship between two users using SQLAlchemy ORM. + Stores both directions for easy querying. + """ + # Check if friendship already exists + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | + ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) + ).first() + + if existing: + return False # friendship already exists + + # Add both directions + # TODO: Do we want bi directional friendships in the table or just one row? + db.session.add(Friend(user_id=user_id, friend_id=friend_id)) + db.session.add(Friend(user_id=friend_id, friend_id=user_id)) + db.session.commit() + return True \ No newline at end of file diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 3ff148cd9..60c0e8dc3 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -2,14 +2,16 @@ from sqlalchemy.orm import relationship from zeeguu.core.model.db import db from zeeguu.core.model.user import User # assuming you have a User model +from zeeguu.core.model.friend import Friend # assuming you have a User model +from sqlalchemy.exc import NoResultFound class FriendRequest(db.Model): __tablename__ = "friend_requests" __table_args__ = {"mysql_collate": "utf8_bin"} id = Column(Integer, primary_key=True, autoincrement=True) - sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) - receiver_id = Column(Integer, ForeignKey("users.id"), nullable=False) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("user.id"), nullable=False) status = Column( Enum("pending", "accepted", "rejected", name="friend_request_status"), default="pending", @@ -44,8 +46,8 @@ def __init__( def __repr__(self): return "id: "+ str(self.id) + "sender: "+ str(self.sender_id) + "reciever: " + str( self.receiver_id) - - def send_friend_request(sender_id: int, receiver_id: int): + @classmethod + def send_friend_request(cls, sender_id: int, receiver_id: int): """ Send a friend request from sender to receiver. @@ -68,7 +70,7 @@ def send_friend_request(sender_id: int, receiver_id: int): raise ValueError("Sender or receiver does not exist.") # Check for existing friend request - existing_request = db.session.query(FriendRequest).filter( + existing_request = db.session.query(cls).filter( ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) ).first() @@ -88,7 +90,9 @@ def send_friend_request(sender_id: int, receiver_id: int): db.session.refresh(new_request) # To get the ID and timestamps return new_request - def get_friend_requests_for_user(user_id: int, status: str = "pending"): + + @classmethod + def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): """ Get friend requests received by a user. @@ -101,10 +105,61 @@ def get_friend_requests_for_user(user_id: int, status: str = "pending"): List[FriendRequest]: List of friend request objects """ requests = ( - db.session.query(FriendRequest) + db.session.query(cls) .filter(FriendRequest.receiver_id == user_id) .filter(FriendRequest.status == status) .order_by(FriendRequest.created_at.desc()) .all() ) - return requests \ No newline at end of file + return requests + @classmethod + def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: + """Delete a friend request between sender and receiver.""" + try: + fr = db.session.query(cls).filter_by( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" # usually only pending requests can be deleted + ).one() + db.session.delete(fr) + db.session.commit() + return True + except NoResultFound: + return False + + @classmethod + def cancel_sent_request(cls, sender_id: int, receiver_id: int)->bool: + # Look for pending friend request from sender to receiver + request = cls.query.filter_by( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" + ).first() + + if request: + db.session.delete(request) + db.session.commit() + return True + return False + + @classmethod + def accept_friend_request(cls, sender_id: int, receiver_id: int): + try: + # Find the pending request + fr = db.session.query(cls).filter_by( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" + ).one() + + # Update the status + fr.status = "accepted" + fr.responded_at = func.now() + db.session.commit() + db.session.refresh(fr) # refesh with the new values + + # Optionally create a friendship in your friends table + Friend.add_friendship(sender_id, receiver_id) + return fr + except NoResultFound: + return None \ No newline at end of file From 311181ce96ea2902e97bfd176952f10d40000352 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Feb 2026 15:01:43 +0100 Subject: [PATCH 014/142] Deleted cancel friendRequest, because we alreaady have delete_friend_request --- zeeguu/api/endpoints/friends.py | 14 +++++++------- zeeguu/core/model/friend_request.py | 16 +--------------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 81cbd85d4..41e935efc 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -133,21 +133,21 @@ def accept_friend_request(): return "None" return _seralize_friend_request(friend_request) - -@api.route("/cancel_friend_request", methods=["POST"]) +@api.route("/reject_friend_request", methods=["POST"]) @cross_domain def accept_friend_request(): - """ - Cancel the send friend request from the point of view of the sender. - """ sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) is_valid, error = _is_friend_request_valid(sender_id, receiver_id) if not is_valid: return error - is_canceled = FriendRequest.cancel_sent_request(sender_id, receiver_id) - return str(is_canceled) + friend_request = FriendRequest.accept_friend_request(sender_id, receiver_id) + if friend_request is None: + return "None" + return _seralize_friend_request(friend_request) + + @api.route("/unfriend", methods=["POST"]) @cross_domain diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 60c0e8dc3..613e415b5 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -112,6 +112,7 @@ def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): .all() ) return requests + @classmethod def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: """Delete a friend request between sender and receiver.""" @@ -126,21 +127,6 @@ def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: return True except NoResultFound: return False - - @classmethod - def cancel_sent_request(cls, sender_id: int, receiver_id: int)->bool: - # Look for pending friend request from sender to receiver - request = cls.query.filter_by( - sender_id=sender_id, - receiver_id=receiver_id, - status="pending" - ).first() - - if request: - db.session.delete(request) - db.session.commit() - return True - return False @classmethod def accept_friend_request(cls, sender_id: int, receiver_id: int): From 845c9312928db2233713b90e4ce07256093b3e10 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Feb 2026 15:27:13 +0100 Subject: [PATCH 015/142] friends and friend request features --- zeeguu/api/endpoints/friends.py | 29 +++++++++++++++++------------ zeeguu/core/model/friend.py | 18 +++++++++--------- zeeguu/core/model/friend_request.py | 27 +++++++++++++-------------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 41e935efc..077d9cb88 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -95,6 +95,15 @@ def _seralize_friend_request(friend_request: FriendRequest): "status": friend_request.status, } +def _seralize_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, + "status": status, + } + def _is_friend_request_valid(sender_id, receiver_id)-> tuple[bool, str]: if sender_id is None or receiver_id is None: return False, "invalid data sender_id or/and receiver_id" @@ -128,33 +137,29 @@ def accept_friend_request(): if not is_valid: return error - friend_request = FriendRequest.accept_friend_request(sender_id, receiver_id) - if friend_request is None: + friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) + if friendship is None: return "None" - return _seralize_friend_request(friend_request) + return _seralize_friendship(friendship) @api.route("/reject_friend_request", methods=["POST"]) @cross_domain -def accept_friend_request(): +def reject_friend_request(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) is_valid, error = _is_friend_request_valid(sender_id, receiver_id) if not is_valid: return error - friend_request = FriendRequest.accept_friend_request(sender_id, receiver_id) - if friend_request is None: - return "None" - return _seralize_friend_request(friend_request) + is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) + + return str(is_rejected) @api.route("/unfriend", methods=["POST"]) @cross_domain -def accept_friend_request(): - """ - Cancel the send friend request from the point of view of the sender. - """ +def unfriend(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) is_valid, error = _is_friend_request_valid(sender_id, receiver_id) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 477786916..5be669136 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -9,8 +9,8 @@ class Friend(db.Model): __table_args__ = {"mysql_collate": "utf8_bin"} id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - friend_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("user.id"), primary_key=True) + friend_id = Column(Integer, ForeignKey("user.id"), primary_key=True) created_at = Column(DateTime, default=func.now()) # Explicit relationships with primaryjoin @@ -56,7 +56,7 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: return False - def add_friendship(user_id: int, friend_id: int)->bool: + def add_friendship(user_id: int, friend_id: int): """ Adds a friendship between two users using SQLAlchemy ORM. Stores both directions for easy querying. @@ -68,11 +68,11 @@ def add_friendship(user_id: int, friend_id: int)->bool: ).first() if existing: - return False # friendship already exists + return existing # friendship already exists - # Add both directions - # TODO: Do we want bi directional friendships in the table or just one row? - db.session.add(Friend(user_id=user_id, friend_id=friend_id)) - db.session.add(Friend(user_id=friend_id, friend_id=user_id)) + # Add friendship + friendship = Friend(user_id=user_id, friend_id=friend_id) + db.session.add(friendship) db.session.commit() - return True \ No newline at end of file + db.session.refresh(friendship) + return friendship \ No newline at end of file diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 613e415b5..c27928206 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -132,20 +132,19 @@ def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: def accept_friend_request(cls, sender_id: int, receiver_id: int): try: # Find the pending request - fr = db.session.query(cls).filter_by( - sender_id=sender_id, - receiver_id=receiver_id, - status="pending" - ).one() - - # Update the status - fr.status = "accepted" - fr.responded_at = func.now() - db.session.commit() - db.session.refresh(fr) # refesh with the new values - + cls.delete_friend_request(sender_id, receiver_id) # Optionally create a friendship in your friends table - Friend.add_friendship(sender_id, receiver_id) - return fr + friendship = Friend.add_friendship(sender_id, receiver_id) + return friendship + except NoResultFound: + return None + + @classmethod + def reject_friend_request(cls, sender_id: int, receiver_id: int): + try: + + # We just delete the friend request from the database + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + return is_deleted except NoResultFound: return None \ No newline at end of file From fe4ec589e96a627d2c21c2ccb7c38733165a89be Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Feb 2026 15:39:12 +0100 Subject: [PATCH 016/142] working on error responses --- zeeguu/api/endpoints/friends.py | 39 +++++++++++++++++---------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 077d9cb88..5bb88364d 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -76,17 +76,15 @@ def send_friend_request(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) - if sender_id is None or receiver_id is None: - return "error" - - if sender_id == receiver_id: - return "error" # TODO: Handle error + status_code, error = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + flask.abort(status_code, error) friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) return _seralize_friend_request(friend_request) def _seralize_friend_request(friend_request: FriendRequest): - return { + result = { "id": friend_request.id, "sender_id": friend_request.sender_id, "receiver_id": friend_request.receiver_id, @@ -94,24 +92,26 @@ def _seralize_friend_request(friend_request: FriendRequest): "reponded_at": friend_request.responded_at, "status": friend_request.status, } + return json_result(result) def _seralize_friendship(friendship: Friend, status: str = "accepted"): - return { + result = { "id": friendship.id, "sender_id": friendship.user_id, "receiver_id": friendship.friend_id, "created_at": friendship.created_at, "status": status, } + return json_result(result) -def _is_friend_request_valid(sender_id, receiver_id)-> tuple[bool, str]: +def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: if sender_id is None or receiver_id is None: - return False, "invalid data sender_id or/and receiver_id" + return 422, "invalid data sender_id or/and receiver_id" if sender_id == receiver_id: - return False, "cannot send friend request to yourself" + return 422, "cannot send friend request to yourself" - return True, "ok" + return 200, "ok" @api.route("/delete_friend_request", methods=["POST"]) @cross_domain @@ -133,13 +133,14 @@ def delete_friend_reuest(): def accept_friend_request(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) - is_valid, error = _is_friend_request_valid(sender_id, receiver_id) - if not is_valid: - return error + status_code, error = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + return flask.abort(status_code, error) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: - return "None" + return flask.abort(404, "No friend request found to accpet") + return _seralize_friendship(friendship) @api.route("/reject_friend_request", methods=["POST"]) @@ -147,13 +148,13 @@ def accept_friend_request(): def reject_friend_request(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) - is_valid, error = _is_friend_request_valid(sender_id, receiver_id) - if not is_valid: - return error + statis_code, error = _is_friend_request_valid(sender_id, receiver_id) + if statis_code >= 400: + return flask.abort(statis_code, error) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) - return str(is_rejected) + return From ac6a606e8b33f3f3e298cb43b9b180918c915724 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 26 Feb 2026 15:51:06 +0100 Subject: [PATCH 017/142] Minor fixes --- tools/migrations/26-02-19--add_badges.sql | 2 +- zeeguu/api/endpoints/badges.py | 20 ++++++++++---------- zeeguu/core/model/badge.py | 2 +- zeeguu/core/model/badge_level.py | 2 +- zeeguu/core/model/user_badge_level.py | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index 0ca58ab35..64867049b 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -5,7 +5,7 @@ CREATE TABLE badge ( code VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, -- We could store a template string, and would interpolate the target values. - is_hidden BOOLEAN DEFAULT FALSE + is_hidden BOOLEAN DEFAULT FALSE, UNIQUE(code) ); diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 9073aaf08..0bc99fc5e 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -21,23 +21,23 @@ def get_badges_for_user(user_id: int): achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} result = [] for badge in badges: - levels = [] - for level in badge.levels: # Assuming Badge has a .levels relationship - achieved = level.id in achieved_map - achieved_at = achieved_map[level.id].achieved_at if achieved else None - levels.append({ - "level": level.level, - "target_value": level.target_value, - "icon_url": level.icon_url, + badge_levels = [] + for badge_level in badge.badge_levels: + achieved = badge_level.id in achieved_map + achieved_at = achieved_map[badge_level.id].achieved_at if achieved else None + badge_levels.append({ + "badge_level": badge_level.level, + "target_value": badge_level.target_value, + "icon_url": badge_level.icon_url, "achieved": achieved, "achieved_at": achieved_at.isoformat() if achieved_at else None, - "is_shown": level.is_shown + "is_shown": badge_level.is_shown }) result.append({ "badge_id": badge.id, "name": badge.name, "description": badge.description, - "levels": levels, + "levels": badge_levels, }) return json_result(result) diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 7be417998..02910ef05 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -22,7 +22,7 @@ class Badge(db.Model): ) # Relationships - levels = db.relationship("BadgeLevel", back_populates="badge", cascade="all, delete-orphan") + badge_levels = db.relationship("BadgeLevel", back_populates="badge", cascade="all, delete-orphan") def __repr__(self): return f"" diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 500a8d2bd..0cea72755 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -17,7 +17,7 @@ class BadgeLevel(db.Model): ) # Relationships - badge = db.relationship("Badge", back_populates="levels") + badge = db.relationship("Badge", back_populates="badge_levels") user_badge_levels = db.relationship("UserBadgeLevel", back_populates="badge_level", cascade="all, delete-orphan") def __repr__(self): diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index 867509e26..fc4d0cd3e 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -40,7 +40,7 @@ def __init__( self.achieved_at = achieved_at def __repr__(self): - return f"" + return f"" @classmethod def find_all(cls, user_id: int): @@ -60,7 +60,7 @@ def find_all_not_shown(cls, user_id: int): @classmethod def find(cls, user_id: int, badge_level_ids: list[int]): - """Find user badge levels for a specific user_id and badge_level_id.""" + """Find user badge levels for a specific user_id and a list of badge_level_ids.""" return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() @classmethod From 45f6f2ade4dde05f668a7018248b607971f336e1 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 26 Feb 2026 16:04:34 +0100 Subject: [PATCH 018/142] updated migration --- tools/migrations/26-02-24-friendship_system.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index 09c85bd2d..cb1bb7af9 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -1,5 +1,6 @@ -- Denormalized friends table CREATE TABLE friends ( + id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, friend_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, From 66491f9108c6d91b9e9ca23d1d92bf0b00d07e48 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 26 Feb 2026 16:08:50 +0100 Subject: [PATCH 019/142] Updated friends table the unique constraints --- tools/migrations/26-02-24-friendship_system.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index cb1bb7af9..08cae5566 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -4,7 +4,8 @@ CREATE TABLE friends ( user_id INT NOT NULL, friend_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, friend_id), + CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id), + CONSTRAINT unique_friend_user UNIQUE (friend_id, user_id), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (friend_id) REFERENCES users(id) ); From 1edcd29f13d85ec7a19f002849250b37d1b156ed Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 26 Feb 2026 16:35:59 +0100 Subject: [PATCH 020/142] fixed database table errors in tests --- zeeguu/core/model/friend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 5be669136..2c3b581a7 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -9,8 +9,8 @@ class Friend(db.Model): __table_args__ = {"mysql_collate": "utf8_bin"} id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("user.id"), primary_key=True) - friend_id = Column(Integer, ForeignKey("user.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) created_at = Column(DateTime, default=func.now()) # Explicit relationships with primaryjoin From 9f8d53d8ac72320301762c33c47102d8b8c5faf7 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 13:46:38 +0100 Subject: [PATCH 021/142] Fix streak to only update on actual practice activities Previously, the daily streak was updated on ANY authenticated API call (via the @requires_session decorator), causing streaks to continue even when users just opened the app without practicing. Changes: - Add reset_streak_if_broken() to UserLanguage - resets streak to 0 if user hasn't practiced in 2+ days (called on login) - Change @requires_session to call reset_streak_if_broken() instead of update_streak_if_needed() - only resets broken streaks, doesn't increment - Add update_user_streak() helper function for practice endpoints - Add streak updates to actual practice endpoints: - /report_exercise_outcome (completing exercises) - /reading_session_start (starting to read) - /listening_session_start (starting to listen) - /get_one_translation (translating words while reading) Co-Authored-By: Claude Opus 4.5 --- zeeguu/api/endpoints/exercises.py | 7 ++++-- zeeguu/api/endpoints/listening_sessions.py | 6 ++++- zeeguu/api/endpoints/reading_sessions.py | 6 ++++- zeeguu/api/endpoints/translation.py | 5 ++++- zeeguu/api/utils/__init__.py | 2 +- zeeguu/api/utils/route_wrappers.py | 26 ++++++++++++++++++++-- zeeguu/core/model/user.py | 9 +------- zeeguu/core/model/user_language.py | 19 ++++++++++++++++ 8 files changed, 64 insertions(+), 16 deletions(-) diff --git a/zeeguu/api/endpoints/exercises.py b/zeeguu/api/endpoints/exercises.py index dabb5b053..67d1c16b1 100644 --- a/zeeguu/api/endpoints/exercises.py +++ b/zeeguu/api/endpoints/exercises.py @@ -6,7 +6,7 @@ from zeeguu.core.model.bookmark import Bookmark from zeeguu.core.model.user import User -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean from . import api, db_session @@ -212,7 +212,7 @@ def report_exercise_outcome(): user_word = UserWord.query.get(user_word_id) if not user_word: return "FAIL - UserWord not found" - + user_word.report_exercise_outcome( db_session, source, @@ -222,6 +222,9 @@ def report_exercise_outcome(): other_feedback, ) + # Update daily streak when user completes an exercise + update_user_streak() + return "OK" except Exception as e: traceback.print_exc() diff --git a/zeeguu/api/endpoints/listening_sessions.py b/zeeguu/api/endpoints/listening_sessions.py index 25706adce..fd1c58933 100644 --- a/zeeguu/api/endpoints/listening_sessions.py +++ b/zeeguu/api/endpoints/listening_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserListeningSession @@ -20,6 +20,10 @@ def listening_session_start(): session = UserListeningSession._create_new_session( db_session, flask.g.user_id, daily_audio_lesson_id, platform=platform ) + + # Update daily streak when user starts listening + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/reading_sessions.py b/zeeguu/api/endpoints/reading_sessions.py index 44181310d..84ff24d42 100644 --- a/zeeguu/api/endpoints/reading_sessions.py +++ b/zeeguu/api/endpoints/reading_sessions.py @@ -2,7 +2,7 @@ from flask import request from . import api, db_session -from zeeguu.api.utils import requires_session, json_result +from zeeguu.api.utils import requires_session, json_result, update_user_streak from .helpers.activity_sessions import update_activity_session from ...core.model import UserReadingSession from datetime import datetime @@ -26,6 +26,10 @@ def reading_session_start(): session = UserReadingSession(flask.g.user_id, article_id, datetime.now(), reading_source, platform) db_session.add(session) db_session.commit() + + # Update daily streak when user starts reading + update_user_streak() + return json_result(dict(id=session.id)) diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 5e1b6c126..12871566a 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -8,7 +8,7 @@ from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, update_user_streak from zeeguu.core.translation_services.translator import ( get_next_results, contribute_trans, @@ -158,6 +158,9 @@ def get_one_translation(from_lang_code, to_lang_code): f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) + # Update daily streak when user translates a word (active reading practice) + update_user_streak() + return json_result( { "translation": t1["translation"], diff --git a/zeeguu/api/utils/__init__.py b/zeeguu/api/utils/__init__.py index c2a7a07db..45891506c 100644 --- a/zeeguu/api/utils/__init__.py +++ b/zeeguu/api/utils/__init__.py @@ -1,3 +1,3 @@ -from .route_wrappers import cross_domain, requires_session +from .route_wrappers import cross_domain, requires_session, update_user_streak from .json_result import json_result from .parse_json_boolean import parse_json_boolean diff --git a/zeeguu/api/utils/route_wrappers.py b/zeeguu/api/utils/route_wrappers.py index bed68fe8d..115889fad 100644 --- a/zeeguu/api/utils/route_wrappers.py +++ b/zeeguu/api/utils/route_wrappers.py @@ -89,12 +89,13 @@ def wrapped_view(*args, **kwargs): if user: user.update_last_seen_if_needed(db.session) - # Update per-language streak for the user's current learned language + # Reset streak if user hasn't practiced in 2+ days + # (streak is only incremented in actual practice endpoints) if user.learned_language: user_language = UserLanguage.find_or_create( db.session, user, user.learned_language ) - user_language.update_streak_if_needed(db.session) + user_language.reset_streak_if_broken(db.session) # Commit immediately since this is a simple timestamp update db.session.commit() @@ -193,3 +194,24 @@ def wrapped_view(*args, **kwargs): return wrapped_view +def update_user_streak(): + """ + Call this in practice endpoints to update the user's daily streak. + Should be called when user performs actual practice activities: + - Completing exercises + - Reading articles (creating bookmarks/translations) + - Listening to audio lessons + """ + from zeeguu.core.model import User + from zeeguu.core.model.user_language import UserLanguage + from zeeguu.core.model.db import db + + user = User.find_by_id(flask.g.user_id) + if user and user.learned_language: + user_language = UserLanguage.find_or_create( + db.session, user, user.learned_language + ) + user_language.update_streak_if_needed(db.session) + db.session.commit() + + diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264a..61b5848d6 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -401,19 +401,12 @@ def active_during_recent(self, days: int = 30): def update_last_seen_if_needed(self, session=None): """ Update last_seen timestamp, but only once per day to minimize database writes. - Also maintains the daily_streak counter. + Note: daily_streak is now tracked per-language in UserLanguage model. """ now = datetime.datetime.now() # Only update if last_seen is None or it's a different day if not self.last_seen or self.last_seen.date() < now.date(): - if not self.last_seen: - self.daily_streak = 1 - elif self.last_seen.date() == now.date() - datetime.timedelta(days=1): - self.daily_streak = (self.daily_streak or 0) + 1 - else: - self.daily_streak = 1 - self.last_seen = now if session: session.add(self) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 128e444ea..5cba5cef1 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -123,6 +123,7 @@ def all_for_user(cls, user): def update_streak_if_needed(self, session=None): """ Update last_practiced timestamp and daily_streak counter for this language. + Call this when user performs actual practice (exercises, reading, etc.). Only updates once per day to minimize database writes. """ now = datetime.datetime.now() @@ -138,3 +139,21 @@ def update_streak_if_needed(self, session=None): self.last_practiced = now if session: session.add(self) + + def reset_streak_if_broken(self, session=None): + """ + Reset streak to 0 if user hasn't practiced since yesterday. + Call this on login/session validation to ensure streak reflects reality. + Does NOT update last_practiced or increment streak. + """ + if not self.last_practiced: + return + + now = datetime.datetime.now() + yesterday = now.date() - datetime.timedelta(days=1) + + # If last practice was before yesterday, streak is broken + if self.last_practiced.date() < yesterday: + self.daily_streak = 0 + if session: + session.add(self) From 315446bb465ab02da6f7c4ecf3ea3426b330f79d Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 17:15:06 +0100 Subject: [PATCH 022/142] Add max_streak tracking to preserve best streak history Saves max_streak and max_streak_date before resetting daily_streak, so users can see their all-time best even after breaking a streak. Co-Authored-By: Claude Opus 4.5 --- .../26-02-26--add_max_streak_to_user_language.sql | 10 ++++++++++ zeeguu/api/endpoints/daily_streak.py | 6 +++++- zeeguu/core/model/user_language.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tools/migrations/26-02-26--add_max_streak_to_user_language.sql diff --git a/tools/migrations/26-02-26--add_max_streak_to_user_language.sql b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql new file mode 100644 index 000000000..8d96a32eb --- /dev/null +++ b/tools/migrations/26-02-26--add_max_streak_to_user_language.sql @@ -0,0 +1,10 @@ +-- Add max_streak tracking to user_language table +ALTER TABLE user_language +ADD COLUMN max_streak INT NOT NULL DEFAULT 0, +ADD COLUMN max_streak_date DATETIME NULL; + +-- Seed max_streak from current daily_streak for users with active streaks +UPDATE user_language +SET max_streak = daily_streak, + max_streak_date = last_practiced +WHERE daily_streak > 0; diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index ee4c4edf9..45cfa1896 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -14,4 +14,8 @@ def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) - return json_result({"daily_streak": user_language.daily_streak or 0}) + return json_result({ + "daily_streak": user_language.daily_streak or 0, + "max_streak": user_language.max_streak or 0, + "max_streak_date": user_language.max_streak_date.strftime("%Y-%m-%d") if user_language.max_streak_date else None, + }) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5cba5cef1..bd96739b3 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -48,6 +48,8 @@ class UserLanguage(db.Model): last_practiced = Column(DateTime, nullable=True) daily_streak = Column(Integer, default=0) + max_streak = Column(Integer, default=0) + max_streak_date = Column(DateTime, nullable=True) def __init__( self, @@ -134,9 +136,12 @@ def update_streak_if_needed(self, session=None): elif self.last_practiced.date() == now.date() - datetime.timedelta(days=1): self.daily_streak = (self.daily_streak or 0) + 1 else: + # Gap in practice - save max before resetting + self._update_max_streak_if_needed() self.daily_streak = 1 self.last_practiced = now + self._update_max_streak_if_needed() if session: session.add(self) @@ -154,6 +159,13 @@ def reset_streak_if_broken(self, session=None): # If last practice was before yesterday, streak is broken if self.last_practiced.date() < yesterday: + self._update_max_streak_if_needed() self.daily_streak = 0 if session: session.add(self) + + def _update_max_streak_if_needed(self): + """Update max_streak if current streak exceeds it.""" + if self.daily_streak > (self.max_streak or 0): + self.max_streak = self.daily_streak + self.max_streak_date = self.last_practiced From 18078a1c7a5b9ddd6c12427d2a7f85ac32b048f0 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 26 Feb 2026 20:03:26 +0100 Subject: [PATCH 023/142] Working on migration for adding username --- tools/migrations/26-02-24-a-add_username.sql | 21 +++++++ tools/migrations/26-02-24-b-add_username.py | 64 ++++++++++++++++++++ zeeguu/core/model/user.py | 1 + 3 files changed, 86 insertions(+) create mode 100644 tools/migrations/26-02-24-a-add_username.sql create mode 100644 tools/migrations/26-02-24-b-add_username.py diff --git a/tools/migrations/26-02-24-a-add_username.sql b/tools/migrations/26-02-24-a-add_username.sql new file mode 100644 index 000000000..6e409f7b5 --- /dev/null +++ b/tools/migrations/26-02-24-a-add_username.sql @@ -0,0 +1,21 @@ +ALTER TABLE user +ADD COLUMN username VARCHAR(50); + +-- This is maybe needed +-- SET SQL_SAFE_UPDATES = 0; + +-- Option 1 user_ +UPDATE user +SET username = CONCAT('user_', id) +WHERE id IS NOT NULL +AND username IS NULL; + +-- In that case remember to enable it again +SET SQL_SAFE_UPDATES = 1; + +-- Change the column to be not null and unique +ALTER TABLE user +MODIFY username VARCHAR(50) NOT NULL; + +ALTER TABLE user +ADD CONSTRAINT unique_username UNIQUE (username); \ No newline at end of file diff --git a/tools/migrations/26-02-24-b-add_username.py b/tools/migrations/26-02-24-b-add_username.py new file mode 100644 index 000000000..10a916412 --- /dev/null +++ b/tools/migrations/26-02-24-b-add_username.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +Migration script to backfill reading_session_id on existing bookmarks. + +For each bookmark without a reading_session_id, finds the reading session where: +- Same user +- Same article +- Bookmark creation time falls within the reading session's time window + +Run with: source ~/.venvs/z_env/bin/activate && python tools/migrations/25-12-11--backfill_reading_session_id_on_bookmarks.py +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from zeeguu.core.model.user import User +from zeeguu.core.model import db +import random + +ADJECTIVES = [ + "brave", "clever", "curious", "silent", "rapid", + "happy", "bright", "nordic", "bold", "calm" +] + +NOUNS = [ + "otter", "falcon", "wolf", "learner", + "linguist", "explorer", "reader", "thinker" +] + + +def generate_username(): + adjective = random.choice(ADJECTIVES) + noun = random.choice(NOUNS) + number = random.randint(1, 999) + + return f"{adjective}_{noun}{number}" + +def generate_unique_username(): + + while True: + username = generate_username() + exists = User.query.filter_by(username=username).first() + if not exists: + return username + +def populate_usernames(): + users = User.query.all() + for user in users: + user.username = generate_unique_username() + db.session.commit() + + +if __name__ == "__main__": + + from zeeguu.api.app import create_app + from zeeguu.core.model import db + + app = create_app() + app.app_context().push() + + print("Starting random username population...") + populate_usernames() + print("Username population completed.") \ No newline at end of file diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264a..45a2a772b 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -44,6 +44,7 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) name = db.Column(db.String(255)) + username = db.Column(db.String(50), unique=True, index=True) invitation_code = db.Column(db.String(255)) password = db.Column(db.String(255)) password_salt = db.Column(db.String(255)) From d6dcfd5fb84552d6b2682582485be5bc9d96c586 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Fri, 27 Feb 2026 16:04:52 +0100 Subject: [PATCH 024/142] Added support for several badges --- zeeguu/api/endpoints/badges.py | 38 +------------------ zeeguu/api/endpoints/translation.py | 6 +-- .../audio_lessons/daily_lesson_generator.py | 9 +++++ zeeguu/core/badges/__init__.py | 0 zeeguu/core/badges/badge_progress.py | 35 +++++++++++++++++ zeeguu/core/model/badge.py | 9 +++-- zeeguu/core/model/user.py | 18 +++++++++ zeeguu/core/model/user_badge_level.py | 2 +- zeeguu/core/model/user_language.py | 3 ++ zeeguu/core/model/user_word.py | 6 +++ 10 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 zeeguu/core/badges/__init__.py create mode 100644 zeeguu/core/badges/badge_progress.py diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 0bc99fc5e..2f09a27e8 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,12 +1,10 @@ from flask import request -from zeeguu.core.model.badge import BadgeCode from zeeguu.core.model.badge import Badge -from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.user_badge_level import UserBadgeLevel from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from . import api, db_session +from . import api # --------------------------------------------------------------------------- @@ -62,37 +60,3 @@ def update_badge_progress(): if not user_badge_level: return json_result({"error": "User badge level not found"}, status=404) - -def update_badge_levels(badge_code: BadgeCode, user_id: int, current_value: int) -> list[UserBadgeLevel]: - """ - Award all achievable badge levels a user doesn't have yet for a specific badge. - - Returns only newly created UserBadgeLevel objects. - """ - badge = Badge.find(badge_code) - if not badge: - return [] - - badge_level_ids = [ - level.id - for level in BadgeLevel.find_all_achievable(badge_id=badge.id, current_value=current_value) - ] - - if not badge_level_ids: - return [] - - user_badge_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) - owned_ids = {lvl.badge_level_id for lvl in user_badge_levels} - - missing_ids = set(badge_level_ids) - owned_ids - created_badges: list[UserBadgeLevel] = [] - - for level_id in missing_ids: - new_badge = UserBadgeLevel(user_id=user_id, badge_level_id=level_id) - db_session.add(new_badge) - created_badges.append(new_badge) - - if missing_ids: - db_session.commit() - - return created_badges diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index 5df941f8b..e3a364eeb 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -6,7 +6,6 @@ from flask import request from python_translators.translation_query import TranslationQuery -from zeeguu.core.model.badge import BadgeCode from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.parse_json_boolean import parse_json_boolean from zeeguu.api.utils.route_wrappers import cross_domain, requires_session @@ -26,7 +25,6 @@ from zeeguu.core.model.text import Text from . import api, db_session from zeeguu.logging import log as zeeguu_log -from zeeguu.api.endpoints.badges import update_badge_levels punctuation_extended = "»«" + punctuation IS_DEV_SKIP_TRANSLATION = int(os.environ.get("DEV_SKIP_TRANSLATION", 0)) == 1 @@ -159,8 +157,10 @@ def get_one_translation(from_lang_code, to_lang_code): log( f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) + from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode current_bookmark_count = len(Bookmark.find_by_specific_user(user)) - update_badge_levels(badge_code=BadgeCode.MEANING_BUILDER, user_id=user.id, current_value=current_bookmark_count) + update_badge_levels(db_session, BadgeCode.TRANSLATED_WORDS, user.id, current_bookmark_count) + db_session.commit() return json_result( { diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index 31aabc0ce..7b1e00f11 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -745,6 +745,15 @@ def update_lesson_state_for_user(self, user, lesson_id, state_data): # Send email notification for lesson completion self._send_lesson_completion_notification(lesson, user) + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels + completed_lessons = ( + DailyAudioLesson.query + .filter_by(user_id=user.id) + .filter(DailyAudioLesson.completed_at.isnot(None)) + .count() + ) + update_badge_levels(db.session, BadgeCode.COMPLETED_AUDIO_LESSONS, user.id, completed_lessons) + else: return { "error": f"Invalid action: {action}. Must be 'play', 'pause', 'resume', or 'complete'", diff --git a/zeeguu/core/badges/__init__.py b/zeeguu/core/badges/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py new file mode 100644 index 000000000..5ae542163 --- /dev/null +++ b/zeeguu/core/badges/badge_progress.py @@ -0,0 +1,35 @@ +from zeeguu.core.model.badge import BadgeCode, Badge +from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.core.model.user_badge_level import UserBadgeLevel + + +def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current_value: int) -> list[UserBadgeLevel]: + """ + Award all achievable badge levels a user doesn't have yet for a specific badge. + + Returns only newly created UserBadgeLevel objects. + """ + badge = Badge.find(badge_code) + if not badge: + return [] + + badge_level_ids = [ + level.id + for level in BadgeLevel.find_all_achievable(badge_id=badge.id, current_value=current_value) + ] + + if not badge_level_ids: + return [] + + user_badge_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) + owned_ids = {lvl.badge_level_id for lvl in user_badge_levels} + + missing_ids = set(badge_level_ids) - owned_ids + created_badges: list[UserBadgeLevel] = [] + + for level_id in missing_ids: + new_badge = UserBadgeLevel(user_id=user_id, badge_level_id=level_id) + db_session.add(new_badge) + created_badges.append(new_badge) + + return created_badges \ No newline at end of file diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 02910ef05..3eaa1979b 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -1,10 +1,13 @@ import enum +from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.db import db - class BadgeCode(enum.Enum): - MEANING_BUILDER = 'MEANING_BUILDER' + TRANSLATED_WORDS = 'TRANSLATED_WORDS' + CORRECT_EXERCISES= 'CORRECT_EXERCISES' + COMPLETED_AUDIO_LESSONS = 'COMPLETED_AUDIO_LESSONS' + STREAK_COUNT = 'STREAK_COUNT' class Badge(db.Model): @@ -22,7 +25,7 @@ class Badge(db.Model): ) # Relationships - badge_levels = db.relationship("BadgeLevel", back_populates="badge", cascade="all, delete-orphan") + badge_levels = db.relationship(BadgeLevel, back_populates="badge", cascade="all, delete-orphan") def __repr__(self): return f"" diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264a..158a39d16 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -459,6 +459,24 @@ def isTeacher(self): return False + def correct_exercises_completed(self): + """ + Returns the total number of correct exercises for a user. + """ + from zeeguu.core.model.user_word import UserWord + from zeeguu.core.model.exercise import Exercise + from zeeguu.core.model import ExerciseOutcome + + result = ( + db.session.query(Exercise) + .join(UserWord, Exercise.user_word_id == UserWord.id) + .join(ExerciseOutcome, Exercise.outcome_id == ExerciseOutcome.id) + .filter(UserWord.user_id == self.id) + .filter(ExerciseOutcome.outcome.in_(ExerciseOutcome.correct_outcomes)) + ).count() + + return result + @classmethod @sqlalchemy.orm.validates("email") def validate_email(cls, col, email): diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index fc4d0cd3e..e3c955be3 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -40,7 +40,7 @@ def __init__( self.achieved_at = achieved_at def __repr__(self): - return f"" + return f"" @classmethod def find_all(cls, user_id: int): diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 128e444ea..9fd8847a6 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -138,3 +138,6 @@ def update_streak_if_needed(self, session=None): self.last_practiced = now if session: session.add(self) + + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels + update_badge_levels(session, BadgeCode.STREAK_COUNT, self.user_id, self.daily_streak) diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index f9da142ed..2cd516b6f 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -392,6 +392,12 @@ def report_exercise_outcome( ) db_session.add(exercise) + if source.source != "DAILY_AUDIO_LESSON" and outcome.correct: + from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode + correct_exercise_count = self.user.correct_exercises_completed() + update_badge_levels(db_session, BadgeCode.CORRECT_EXERCISES, self.user.id, correct_exercise_count) + + scheduler = self.get_scheduler() scheduler.update(db_session, self, exercise_outcome, time) From a7c980f3469469305171a2f073eb7565d6cee7a3 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Fri, 27 Feb 2026 16:53:36 +0100 Subject: [PATCH 025/142] Added support for learned words badge --- zeeguu/core/model/badge.py | 1 + zeeguu/core/model/user_word.py | 7 +++++++ .../core/word_scheduling/basicSR/four_levels_per_word.py | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 3eaa1979b..ef212080b 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -8,6 +8,7 @@ class BadgeCode(enum.Enum): CORRECT_EXERCISES= 'CORRECT_EXERCISES' COMPLETED_AUDIO_LESSONS = 'COMPLETED_AUDIO_LESSONS' STREAK_COUNT = 'STREAK_COUNT' + LEARNED_WORDS = 'LEARNED_WORDS' class Badge(db.Model): diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index 2cd516b6f..74f348d54 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -408,6 +408,13 @@ def report_exercise_outcome( # self.update_fit_for_study(db_session) # self.update_learned_status(db_session) + @classmethod + def find_user_learned_words_count(cls, user_id): + """ + Finds the number of learned words for a specific user. + """ + return cls.query.filter_by(user_id=user_id).filter(UserWord.learned_time.isnot(None)).count() + @classmethod def find_or_create(cls, session, user, meaning, is_user_added=False): """ diff --git a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py index 027eae799..f6a7df98b 100644 --- a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py +++ b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py @@ -1,6 +1,8 @@ from .basicSR import ONE_DAY, BasicSRSchedule from datetime import datetime, timedelta +from ...model import UserWord + MAX_LEVEL = 4 # Minimum delay before a word reappears in exercises. @@ -61,6 +63,10 @@ def update_schedule(self, db_session, correctness, exercise_time: datetime = Non else: self.set_meaning_as_learned(db_session) + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels + user_id = self.user_word.user.id + update_badge_levels(db_session, BadgeCode.LEARNED_WORDS, user_id, UserWord.find_user_learned_words_count(user_id)) + db_session.commit() # we simply return because the self object will have been deleted inside of the above call return else: From ce2c3d87271cd08542fca85d42f7cafa380b597a Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Fri, 27 Feb 2026 16:56:53 +0100 Subject: [PATCH 026/142] Added support for read articles badge --- zeeguu/api/endpoints/activity_tracking.py | 15 ++++++++++----- zeeguu/core/model/badge.py | 2 +- zeeguu/core/model/user_article.py | 9 +++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/zeeguu/api/endpoints/activity_tracking.py b/zeeguu/api/endpoints/activity_tracking.py index 984dcd38e..7a39cf826 100644 --- a/zeeguu/api/endpoints/activity_tracking.py +++ b/zeeguu/api/endpoints/activity_tracking.py @@ -1,12 +1,13 @@ import flask from flask import request + +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode +from zeeguu.core.model import UserActivityData, User from zeeguu.core.user_activity_hooks.article_interaction_hooks import ( distill_article_interactions, ) - from . import api, db_session -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from zeeguu.core.model import UserActivityData, User @api.route("/upload_user_activity_data", methods=["POST"]) @@ -102,10 +103,10 @@ def _check_and_notify_article_completion_on_scroll(user, form_data): # Debug: log the scroll data structure from zeeguu.logging import log log(f"[article_completion] Scroll data received: {scroll_data[:3] if isinstance(scroll_data, list) and len(scroll_data) > 3 else scroll_data}") - + # Use a more lenient approach or fallback to max percentage completion_percentage = find_last_reading_percentage(scroll_data, max_jump=50, max_total_update=80) - + # Fallback: if the algorithm returns 0 but we have scroll data, use max percentage if completion_percentage == 0 and scroll_data: max_percentage = max([point[1] for point in scroll_data if len(point) >= 2]) / 100.0 @@ -135,6 +136,10 @@ def _check_and_notify_article_completion_on_scroll(user, form_data): user_article.completed_at = datetime.now() + # Update READ_ARTICLES badge progress + current_read_article_count = UserArticle.get_completed_article_count_by_user(user.id) + update_badge_levels(db_session, BadgeCode.READ_ARTICLES, user.id, current_read_article_count) + # Send notification if enabled from flask import current_app diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index ef212080b..f1799063c 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -9,7 +9,7 @@ class BadgeCode(enum.Enum): COMPLETED_AUDIO_LESSONS = 'COMPLETED_AUDIO_LESSONS' STREAK_COUNT = 'STREAK_COUNT' LEARNED_WORDS = 'LEARNED_WORDS' - + READ_ARTICLES = 'READ_ARTICLES' class Badge(db.Model): __tablename__ = "badge" diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index b3ed1a7f2..8aa7aa73b 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -683,3 +683,12 @@ def article_infos(cls, user, articles, select_appropriate=True): cls.user_article_info(user, article, tokenization_cache=existing_caches.get(article.id)) for article in articles_to_process ] + + @classmethod + def get_completed_article_count_by_user(cls, user_id): + return ( + cls.query + .filter_by(user_id=user_id) + .filter(cls.completed_at is not None) + .count() + ) From 9b8847370252c1685f6e13c81e688456ab58b1c1 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Sun, 1 Mar 2026 13:30:22 +0100 Subject: [PATCH 027/142] Working on searching for friends and search be username --- tools/migrations/26-02-24-b-add_username.py | 2 +- zeeguu/api/endpoints/friends.py | 39 +++++++++++++++++++-- zeeguu/core/model/friend.py | 35 ++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/tools/migrations/26-02-24-b-add_username.py b/tools/migrations/26-02-24-b-add_username.py index 10a916412..80456833e 100644 --- a/tools/migrations/26-02-24-b-add_username.py +++ b/tools/migrations/26-02-24-b-add_username.py @@ -7,7 +7,7 @@ - Same article - Bookmark creation time falls within the reading session's time window -Run with: source ~/.venvs/z_env/bin/activate && python tools/migrations/25-12-11--backfill_reading_session_id_on_bookmarks.py +Run with: source ~/.venvs/z_env/bin/activate && python tools/migrations/26-02-24-b-add_username.py """ import sys import os diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 077d9cb88..0da15a43b 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -169,7 +169,40 @@ def unfriend(): return str(is_removed) -def search_by_username(): - pass +@api.route("/users/search/", methods=["GET"]) +@cross_domain +def search_by_username(username): + pass + +@api.route("/users/discover//", methods=["GET"]) +@cross_domain +def discover_by_username(user_id, username): + """ + Search for new friends with of a user by user_id + """ + user_id = int(user_id) + if user_id is None: + return flask.abort(400, "missing user_id") + + new_friends = Friend.search_for_new_friends(user_id, username) + return [_serialize_user(user) for user in new_friends] + +def _serialize_user(user: User): + return { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + } + +@api.route("/users/search//friends/", methods=["GET"]) +@cross_domain +def search_friends(user_id, username): + """ + Search for friends with of a user by user_id + """ + user_id = int(user_id) + if user_id is None: + return flask.abort(400, "missing user_id") + - diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 2c3b581a7..cca3d4e51 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -56,6 +56,41 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: return False + + @staticmethod + def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: + term = term.strip().lower() + + # Subquery: users already connected (either direction) + # existing_friends = db.session.query(Friend.user_id, Friend.friend_id).filter( + # or_( + # Friend.user_id == current_user_id, + # Friend.friend_id == current_user_id + # ) + # ) + + # Collect IDs to exclude + friend_ids_subquery = db.session.query( + func.if_( + Friend.user_id == current_user_id, + Friend.friend_id, + Friend.user_id + ) + ).filter( + or_( + Friend.user_id == current_user_id, + Friend.friend_id == current_user_id + ) + ) + + query = User.query.filter( + func.lower(User.username).like(f"%{term}%"), # search + User.id != current_user_id, # exclude self + ~User.id.in_(friend_ids_subquery) # exclude existing friends + ).order_by(User.username).limit(limit) + + return query.all() + def add_friendship(user_id: int, friend_id: int): """ Adds a friendship between two users using SQLAlchemy ORM. From 57ff85ea3a129ba9ff111206139743a3c1686f77 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 1 Mar 2026 13:34:12 +0100 Subject: [PATCH 028/142] Minor changes --- .../26-02-28--insert_default_badges.sql | 59 +++++++++++++++++++ zeeguu/api/endpoints/badges.py | 30 ++++------ zeeguu/core/badges/badge_progress.py | 20 ++++--- zeeguu/core/model/badge.py | 8 +-- zeeguu/core/model/badge_level.py | 4 +- zeeguu/core/model/user_badge_level.py | 15 ++--- 6 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 tools/migrations/26-02-28--insert_default_badges.sql diff --git a/tools/migrations/26-02-28--insert_default_badges.sql b/tools/migrations/26-02-28--insert_default_badges.sql new file mode 100644 index 000000000..b0e3077d2 --- /dev/null +++ b/tools/migrations/26-02-28--insert_default_badges.sql @@ -0,0 +1,59 @@ +-- tools/migrations/26-02-28--insert_default_badge.sql + +INSERT INTO badge (id, code, name, description, is_hidden) +VALUES + (1, 'TRANSLATED_WORDS', 'Meaning Builder', 'Translate {target_value} words while reading.', FALSE), + (2, 'CORRECT_EXERCISES', 'Practice Builder', 'Solve {target_value} exercises correctly.', FALSE), + (3, 'COMPLETED_AUDIO_LESSONS', 'Sound Scholar', 'Complete {target_value} audio lessons.', FALSE), + (4, 'STREAK_COUNT', 'Consistency Champion', 'Maintain your streak for {target_value} days.', FALSE), + (5, 'LEARNED_WORDS', 'Word Collector', 'Learn {target_value} new words.', FALSE), + (6, 'READ_ARTICLES', 'Active Reader', 'Read {target_value} articles.', FALSE); + +INSERT INTO badge_level (id, badge_id, name, level, target_value, icon_url) +VALUES + -- Translated Words + (1, 1, '', 1, 50, NULL), + (2, 1, '', 2, 100, NULL), + (3, 1, '', 3, 500, NULL), + (4, 1, '', 4, 1000, NULL), + (5, 1, '', 5, 2500, NULL), + (6, 1, '', 6, 5000, NULL), + + -- Correct Exercises + (7, 2, '', 1, 10, NULL), + (8, 2, '', 2, 50, NULL), + (9, 2, '', 3, 200, NULL), + (10, 2, '', 4, 500, NULL), + (11, 2, '', 5, 1000, NULL), + (12, 2, '', 6, 5000, NULL), + + -- Completed Audio Lessons + (13, 3, '', 1, 1, NULL), + (14, 3, '', 2, 10, NULL), + (15, 3, '', 3, 50, NULL), + (16, 3, '', 4, 100, NULL), + (17, 3, '', 5, 250, NULL), + (18, 3, '', 6, 500, NULL), + + -- Streak Count + (19, 4, '', 1, 7, NULL), + (20, 4, '', 2, 21, NULL), + (21, 4, '', 3, 60, NULL), + (22, 4, '', 4, 180, NULL), + (23, 4, '', 5, 365, NULL), + + -- Learned Words + (24, 5, '', 1, 10, NULL), + (25, 5, '', 2, 50, NULL), + (26, 5, '', 3, 100, NULL), + (27, 5, '', 4, 250, NULL), + (28, 5, '', 5, 500, NULL), + (29, 5, '', 6, 1000, NULL), + + -- Read Articles + (30, 6, '', 1, 5, NULL), + (31, 6, '', 2, 20, NULL), + (32, 6, '', 3, 50, NULL), + (33, 6, '', 4, 100, NULL), + (34, 6, '', 5, 250, NULL), + (35, 6, '', 6, 500, NULL); diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 2f09a27e8..3ba3ed8b1 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,4 +1,5 @@ from flask import request +from sqlalchemy.orm import joinedload from zeeguu.core.model.badge import Badge from zeeguu.core.model.user_badge_level import UserBadgeLevel @@ -14,13 +15,17 @@ # @requires_session def get_badges_for_user(user_id: int): # Get all badge levels achieved by the user - badges = Badge.query.all() + badges = ( + Badge.query + .options(joinedload(Badge.badge_levels)) + .all() + ) user_badge_levels = UserBadgeLevel.find_all(user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} result = [] for badge in badges: badge_levels = [] - for badge_level in badge.badge_levels: + for badge_level in sorted(badge.badge_levels, key=lambda b: b.level): achieved = badge_level.id in achieved_map achieved_at = achieved_map[badge_level.id].achieved_at if achieved else None badge_levels.append({ @@ -29,7 +34,8 @@ def get_badges_for_user(user_id: int): "icon_url": badge_level.icon_url, "achieved": achieved, "achieved_at": achieved_at.isoformat() if achieved_at else None, - "is_shown": badge_level.is_shown + "is_shown": achieved_map[badge_level.id].is_shown if achieved else False, + "name": badge_level.name }) result.append({ "badge_id": badge.id, @@ -39,24 +45,12 @@ def get_badges_for_user(user_id: int): }) return json_result(result) - -## Update badge progress endpoint (not implemented yet) # --------------------------------------------------------------------------- -@api.route("/update_badge_progress", methods=["POST"]) +@api.route("/badges//not_shown", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain # @requires_session -def update_badge_progress(): - # For badge id - badge_id = request.form.get("badge_id") - user_id = request.form.get("user_id") - - # Validation of inputs - if not badge_id or not user_id: - return json_result({"error": "Missing badge_id or user_id"}, status=400) +def get_not_shown_badge_levels_for_user(user_id: int): + return json_result(UserBadgeLevel.count_user_not_shown(user_id)) - # Get current progress for the badge and user - user_badge_level = UserBadgeLevel.query.filter_by(badge_id=badge_id, user_id=user_id).first() - if not user_badge_level: - return json_result({"error": "User badge level not found"}, status=404) diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py index 5ae542163..e96c64524 100644 --- a/zeeguu/core/badges/badge_progress.py +++ b/zeeguu/core/badges/badge_progress.py @@ -13,18 +13,22 @@ def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current if not badge: return [] - badge_level_ids = [ - level.id - for level in BadgeLevel.find_all_achievable(badge_id=badge.id, current_value=current_value) - ] + badge_level_ids = db_session.scalars( + db_session.query(BadgeLevel.id) + .filter( + BadgeLevel.badge_id == badge.id, + BadgeLevel.target_value <= current_value + ) + .order_by(BadgeLevel.level.asc()) + ).all() if not badge_level_ids: return [] - user_badge_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) - owned_ids = {lvl.badge_level_id for lvl in user_badge_levels} + existing_levels = UserBadgeLevel.find(user_id=user_id, badge_level_ids=badge_level_ids) + owned_ids = {lvl.badge_level_id for lvl in existing_levels} - missing_ids = set(badge_level_ids) - owned_ids + missing_ids = [lvl_id for lvl_id in badge_level_ids if lvl_id not in owned_ids] created_badges: list[UserBadgeLevel] = [] for level_id in missing_ids: @@ -32,4 +36,4 @@ def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current db_session.add(new_badge) created_badges.append(new_badge) - return created_badges \ No newline at end of file + return created_badges diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index f1799063c..c437efd4d 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -15,10 +15,10 @@ class Badge(db.Model): __tablename__ = "badge" id = db.Column(db.Integer, primary_key=True) - code = db.Column(db.String(100), nullable=False) + code = db.Column(db.Enum(BadgeCode), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) - is_hidden = db.Column(db.Boolean, default=False) + is_hidden = db.Column(db.Boolean, nullable=False, default=False) # Constraints __table_args__ = ( @@ -32,6 +32,6 @@ def __repr__(self): return f"" @classmethod - def find(cls, code: BadgeCode): + def find(cls, code: BadgeCode) -> "Badge": """Find badge for a specific code""" - return cls.query.filter_by(code=code.value).first() + return cls.query.filter_by(code=code).first() diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 0cea72755..2585202ae 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -24,7 +24,7 @@ def __repr__(self): return f"" @classmethod - def find_all_achievable(cls, badge_id: int, current_value: int): + def find_all_achievable(cls, badge_id: int, current_value: int) -> list["BadgeLevel"]: """Find all badge levels for a specific badge id that are achievable""" return cls.query.filter( cls.badge_id == badge_id, @@ -32,6 +32,6 @@ def find_all_achievable(cls, badge_id: int, current_value: int): ).all() @classmethod - def find(cls, badge_id: int, level: int): + def find(cls, badge_id: int, level: int) -> "BadgeLevel | None": """Find badge level for a specific badge id and level""" return cls.query.filter_by(badge_id=badge_id, level=level).first() diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index e3c955be3..9a95d5b1e 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -45,22 +45,18 @@ def __repr__(self): @classmethod def find_all(cls, user_id: int): """Find existing user badge levels by user id.""" - try: - return cls.query.filter_by(user_id=user_id).all() - except sqlalchemy.orm.exc.NoResultFound: - return None + return cls.query.filter_by(user_id=user_id).all() @classmethod - def find_all_not_shown(cls, user_id: int): + def count_user_not_shown(cls, user_id: int): """Find existing not shown user badge levels by user id.""" - try: - return cls.query.filter_by(user_id=user_id, is_shown=False).all() - except sqlalchemy.orm.exc.NoResultFound: - return None + return cls.query.filter_by(user_id=user_id, is_shown=False).count() @classmethod def find(cls, user_id: int, badge_level_ids: list[int]): """Find user badge levels for a specific user_id and a list of badge_level_ids.""" + if not badge_level_ids: + return [] return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() @classmethod @@ -74,5 +70,4 @@ def create( ): new = cls(user_id, badge_level_id, achieved_at, is_shown) session.add(new) - session.commit() return new From 85a83646062d50d101226faac494880012ac5bd8 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Mon, 2 Mar 2026 16:45:21 +0100 Subject: [PATCH 029/142] Minor changes --- zeeguu/api/endpoints/badges.py | 34 +++++++++++++++------------ zeeguu/core/model/user_badge_level.py | 8 +++++++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 3ba3ed8b1..41738c914 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,18 +1,29 @@ -from flask import request +import flask from sqlalchemy.orm import joinedload -from zeeguu.core.model.badge import Badge -from zeeguu.core.model.user_badge_level import UserBadgeLevel +from zeeguu.core.model import User from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from . import api +from zeeguu.core.model.badge import Badge +from zeeguu.core.model.user_badge_level import UserBadgeLevel +from . import api, db_session + + +# --------------------------------------------------------------------------- +@api.route("/badges/count_not_shown_badges", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_not_shown_badge_levels_for_user(): + user = User.find_by_id(flask.g.user_id) + return json_result(UserBadgeLevel.count_user_not_shown(user.id)) # --------------------------------------------------------------------------- @api.route("/badges/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain -# @requires_session +@requires_session def get_badges_for_user(user_id: int): # Get all badge levels achieved by the user badges = ( @@ -43,14 +54,7 @@ def get_badges_for_user(user_id: int): "description": badge.description, "levels": badge_levels, }) - return json_result(result) - -# --------------------------------------------------------------------------- -@api.route("/badges//not_shown", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -# @requires_session -def get_not_shown_badge_levels_for_user(user_id: int): - return json_result(UserBadgeLevel.count_user_not_shown(user_id)) - + UserBadgeLevel.update_not_shown_for_user(db_session, user_id) + db_session.commit() + return json_result(result) diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index 9a95d5b1e..b61d01604 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -59,6 +59,14 @@ def find(cls, user_id: int, badge_level_ids: list[int]): return [] return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() + @classmethod + def update_not_shown_for_user(cls, session, user_id: int): + """Updated not shown user badge levels for a specific user_id.""" + badge_level = cls.query.filter_by(user_id=user_id, is_shown=False).all() + for badge_level in badge_level: + badge_level.is_shown = True + session.add(badge_level) + @classmethod def create( cls, From 1b703579265bf0b94d420971db74bb5750945d9e Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 3 Mar 2026 10:40:45 +0100 Subject: [PATCH 030/142] working on endpoints --- zeeguu/api/endpoints/friends.py | 52 +++++++++++++++++---------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 571686a40..d4f290e63 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,46 +1,42 @@ import flask from flask import request -from zeeguu.core.model import Article, Language, User, Topic, UserArticle, UserArticleBrokenReport +from zeeguu.core.model import User from zeeguu.core.model.friend import Friend from zeeguu.core.model.friend_request import FriendRequest -from zeeguu.core.model.article_topic_user_feedback import ArticleTopicUserFeedback from zeeguu.api.utils.json_result import json_result -from zeeguu.core.model.personal_copy import PersonalCopy from sqlalchemy.orm.exc import NoResultFound from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from . import api, db_session -from zeeguu.core.model.article import HTML_TAG_CLEANR +from . import api # import re # from langdetect import detect # import json # from zeeguu.logging import log - - # --------------------------------------------------------------------------- -@api.route("/get_friends/", methods=["GET"]) +@api.route("/get_friends", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain -# @requires_session -def get_friends(user_id): +@requires_session +def get_friends(): """ Get all friends of a user """ - friends = Friend.get_friends(user_id) - for friend in friends: - print(friend.email) - return "ok" - + friends = Friend.get_friends(flask.g.user_id) + return json_result(_seralize_users(friends)) # --------------------------------------------------------------------------- -@api.route("/get_friend_requests/", methods=["GET"]) +@api.route("/get_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain -# @requires_session -def get_friend_requests(user_id): - friendRequest : list[FriendRequest] = FriendRequest.get_friend_requests_for_user(user_id) +@requires_session +def get_friend_requests(): + """ + Get all friend requests of a user + """ + + friendRequest : list[FriendRequest] = FriendRequest.get_friend_requests_for_user(flask.g.user_id) return [_serialize_friend_request(req) for req in friendRequest] @@ -71,7 +67,7 @@ def _serialize_friend_request(fr: FriendRequest): @api.route("/send_friend_request", methods=["POST"]) # --------------------------------------------------------------------------- @cross_domain -# @requires_session +@requires_session def send_friend_request(): sender_id = request.form.get("sender_id", type=int) receiver_id = request.form.get("receiver_id", type=int) @@ -115,13 +111,16 @@ def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: @api.route("/delete_friend_request", methods=["POST"]) @cross_domain +@requires_session def delete_friend_reuest(): - sender_id = request.form.get("sender_id", type=int) + """ + """ + sender_id = flask.g.user_id receiver_id = request.form.get("receiver_id", type=int) is_valid, error = _is_friend_request_valid(sender_id, receiver_id) if not is_valid: - return error + return flask.abort(400, error) is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) return str(is_deleted) @@ -170,12 +169,12 @@ def unfriend(): return str(is_removed) -@api.route("/users/search/", methods=["GET"]) +@api.route("/search_users/", methods=["GET"]) @cross_domain def search_by_username(username): pass -@api.route("/users/discover//", methods=["GET"]) +@api.route("/discover_friends/", methods=["GET"]) @cross_domain def discover_by_username(user_id, username): """ @@ -186,7 +185,7 @@ def discover_by_username(user_id, username): return flask.abort(400, "missing user_id") new_friends = Friend.search_for_new_friends(user_id, username) - return [_serialize_user(user) for user in new_friends] + return json_result(_seralize_users(new_friends)) def _serialize_user(user: User): return { @@ -196,6 +195,9 @@ def _serialize_user(user: User): "email": user.email, } +def _seralize_users(users: list[User]): + return [_serialize_user(user) for user in users] + @api.route("/users/search//friends/", methods=["GET"]) @cross_domain def search_friends(user_id, username): From 0b36f732b823c7031128172b873194fbbc661a31 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 3 Mar 2026 11:15:05 +0100 Subject: [PATCH 031/142] Aligning endpoints to standards --- zeeguu/api/endpoints/friends.py | 247 +++++++++++++++++++------------- 1 file changed, 148 insertions(+), 99 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index d4f290e63..51fd72279 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -20,7 +20,7 @@ @requires_session def get_friends(): """ - Get all friends of a user + Get all friends of current user with flask.g.user_id """ friends = Friend.get_friends(flask.g.user_id) return json_result(_seralize_users(friends)) @@ -36,32 +36,10 @@ def get_friend_requests(): Get all friend requests of a user """ - friendRequest : list[FriendRequest] = FriendRequest.get_friend_requests_for_user(flask.g.user_id) - - return [_serialize_friend_request(req) for req in friendRequest] - -def _serialize_friend_request(fr: FriendRequest): - """ - Serialize a FriendRequest object into JSON-friendly dict. - - Args: - fr (FriendRequest): The friend request object - current_user_id (int): Optional, to simplify sender/receiver info + friendRequest = FriendRequest.get_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) - Returns: - dict: JSON-serializable dictionary - """ - return { - "id": fr.id, - "sender": { - "id": fr.sender.id, # This is the user_id is that nesessary? - "name": fr.sender.name, # This will be updated to username - "email": fr.sender.email, # Is this relevant? - }, - "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, - } # --------------------------------------------------------------------------- @api.route("/send_friend_request", methods=["POST"]) @@ -69,124 +47,190 @@ def _serialize_friend_request(fr: FriendRequest): @cross_domain @requires_session def send_friend_request(): - sender_id = request.form.get("sender_id", type=int) + """ + Send a friend request from sender (current user with flask.g.user_id) to receiver + """ + sender_id = flask.g.user_id receiver_id = request.form.get("receiver_id", type=int) - status_code, error = _is_friend_request_valid(sender_id, receiver_id) + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - flask.abort(status_code, error) + flask.abort(status_code, error_message) friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) - return _seralize_friend_request(friend_request) - -def _seralize_friend_request(friend_request: FriendRequest): - result = { - "id": friend_request.id, - "sender_id": friend_request.sender_id, - "receiver_id": friend_request.receiver_id, - "created_at": friend_request.created_at, - "reponded_at": friend_request.responded_at, - "status": friend_request.status, - } - return json_result(result) - -def _seralize_friendship(friendship: Friend, status: str = "accepted"): - result = { - "id": friendship.id, - "sender_id": friendship.user_id, - "receiver_id": friendship.friend_id, - "created_at": friendship.created_at, - "status": status, - } - return json_result(result) - -def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: - if sender_id is None or receiver_id is None: - return 422, "invalid data sender_id or/and receiver_id" - - if sender_id == receiver_id: - return 422, "cannot send friend request to yourself" + response = _serialize_friend_request(friend_request) + return json_result(response) - return 200, "ok" +# --------------------------------------------------------------------------- @api.route("/delete_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- @cross_domain @requires_session -def delete_friend_reuest(): +def delete_friend_request(): """ + Delete a friend request between sender and receiver """ sender_id = flask.g.user_id receiver_id = request.form.get("receiver_id", type=int) - is_valid, error = _is_friend_request_valid(sender_id, receiver_id) - if not is_valid: - return flask.abort(400, error) + status_code, error = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + return flask.abort(status_code, error) is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) - return str(is_deleted) - - + return json_result(str(is_deleted)) +# --------------------------------------------------------------------------- @api.route("/accept_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- @cross_domain +@requires_session def accept_friend_request(): + """ + Accept a friend request between sender and receiver, and create a friendship + """ + # current user is the receiver of the friend request + receiver_id = flask.g.user_id sender_id = request.form.get("sender_id", type=int) - receiver_id = request.form.get("receiver_id", type=int) + status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: return flask.abort(status_code, error) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: - return flask.abort(404, "No friend request found to accpet") + return flask.abort(404, "No friend request found to accept") - return _seralize_friendship(friendship) + response = _serialize_friendship(friendship) + return json_result(response) +# --------------------------------------------------------------------------- @api.route("/reject_friend_request", methods=["POST"]) +# --------------------------------------------------------------------------- @cross_domain +@requires_session def reject_friend_request(): + """ + Reject a friend request between sender and receiver, and delete the friend request record in the database + """ + # current user is the receiver of the friend request + receiver_id = flask.g.user_id sender_id = request.form.get("sender_id", type=int) - receiver_id = request.form.get("receiver_id", type=int) - statis_code, error = _is_friend_request_valid(sender_id, receiver_id) - if statis_code >= 400: - return flask.abort(statis_code, error) + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + + if status_code >= 400: + return flask.abort(status_code, error_message) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) + return json_result(is_rejected) - return - - - +# --------------------------------------------------------------------------- @api.route("/unfriend", methods=["POST"]) +# --------------------------------------------------------------------------- @cross_domain +@requires_session def unfriend(): - sender_id = request.form.get("sender_id", type=int) + """ + unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database + """ + sender_id = flask.g.user_id receiver_id = request.form.get("receiver_id", type=int) - is_valid, error = _is_friend_request_valid(sender_id, receiver_id) - if not is_valid: - return error + + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + return flask.abort(status_code, error_message) + is_removed = Friend.remove_friendship(sender_id, receiver_id) - return str(is_removed) + return json_result(str(is_removed)) -@api.route("/search_users/", methods=["GET"]) -@cross_domain -def search_by_username(username): - pass +# --------------------------------------------------------------------------- +# Search and discover friends endpoints below +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- @api.route("/discover_friends/", methods=["GET"]) +# --------------------------------------------------------------------------- @cross_domain -def discover_by_username(user_id, username): +@requires_session +def discover_by_username(username): """ Search for new friends with of a user by user_id """ - user_id = int(user_id) - if user_id is None: - return flask.abort(400, "missing user_id") - + user_id = flask.g.user_id new_friends = Friend.search_for_new_friends(user_id, username) return json_result(_seralize_users(new_friends)) +# --------------------------------------------------------------------------- +@api.route("/search_users/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def search_by_username(username): + return flask.abort(501, "Not implemented yet") + +# --------------------------------------------------------------------------- +@api.route("/search_friends/", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def search_friends(username): + """ + Search among friends with for current user with flask.g.user_id + """ + return flask.abort(501, "Not implemented yet") + +# --------------------------- +# Helper functions below +# --------------------------- + +def _serialize_friend_request(friend_request: FriendRequest): + result = { + "id": friend_request.id, + "sender_id": friend_request.sender_id, + "receiver_id": friend_request.receiver_id, + "created_at": friend_request.created_at, + "reponded_at": friend_request.responded_at, + "status": friend_request.status, + } + return result + + +# def _serialize_friend_request(fr: FriendRequest): +# """ +# Serialize a FriendRequest object into JSON-friendly dict. + +# Args: +# fr (FriendRequest): The friend request object +# current_user_id (int): Optional, to simplify sender/receiver info + +# Returns: +# dict: JSON-serializable dictionary +# """ +# return { +# "id": fr.id, +# "sender": { +# "id": fr.sender.id, # This is the user_id is that nesessary? +# "name": fr.sender.name, # This will be updated to username +# "email": fr.sender.email, # Is this relevant? +# }, +# "status": fr.status, +# "created_at": fr.created_at.isoformat() if fr.created_at else None, +# "responded_at": fr.responded_at.isoformat() if fr.responded_at else None, +# } + +def _serialize_friendship(friendship: Friend, status: str = "accepted"): + result = { + "id": friendship.id, + "sender_id": friendship.user_id, + "receiver_id": friendship.friend_id, + "created_at": friendship.created_at, + "status": status, + } + return json_result(result) + def _serialize_user(user: User): return { "id": user.id, @@ -198,14 +242,19 @@ def _serialize_user(user: User): def _seralize_users(users: list[User]): return [_serialize_user(user) for user in users] -@api.route("/users/search//friends/", methods=["GET"]) -@cross_domain -def search_friends(user_id, username): - """ - Search for friends with of a user by user_id + +def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: """ - user_id = int(user_id) - if user_id is None: - return flask.abort(400, "missing user_id") + :param sender_id: the user_id of the sender of the friend request + :param receiver_id: the user_id of the receiver of the friend request + Validate the friend request data, return (status_code, error_message) + :return: (status_code, error_message) + """ + if sender_id is None or receiver_id is None: + return 422, "invalid data sender_id or/and receiver_id" + + if sender_id == receiver_id: + return 422, "cannot send friend request to yourself" + return 200, "ok" \ No newline at end of file From 2234f462a5065bccb208b7ee8574e9e1f20412f7 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 3 Mar 2026 12:05:53 +0100 Subject: [PATCH 032/142] Fixing bug, when creating a user. The username has to be set. --- .../user_account_creation.py | 2 +- zeeguu/core/model/user.py | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index 03ce53ab5..b9c63ecab 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -59,7 +59,7 @@ def create_account( learned_language = Language.find_or_create(learned_language_code) native_language = Language.find_or_create(native_language_code) - + print(f"this is the username:{username}") new_user = User( email, username, diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 45a2a772b..7315b8965 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -76,8 +76,9 @@ def __init__( ): from datetime import datetime - self.email = email - self.name = name + self.email = email + self.name = name # The name of the user + self.username = self.generate_username() # Username is custom name for display self.update_password(password) self.learned_language = learned_language or Language.default_learned() self.native_language = native_language or Language.default_native_language() @@ -86,7 +87,24 @@ def __init__( self.created_at = datetime.now() self.creation_platform = creation_platform + ADJECTIVES = [ + "brave", "clever", "curious", "silent", "rapid", + "happy", "bright", "nordic", "bold", "calm" + ] + + NOUNS = [ + "otter", "falcon", "wolf", "learner", + "linguist", "explorer", "reader", "thinker" + ] + @classmethod + def generate_username(cls): + adjective = random.choice(cls.ADJECTIVES) + noun = random.choice(cls.NOUNS) + number = random.randint(1, 999) + return f"{adjective}_{noun}{number}" + + def create_anonymous( cls, uuid, From 1d644ce1a52beba9bcc5b5de3aec47888fb000c9 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 3 Mar 2026 16:33:13 +0100 Subject: [PATCH 033/142] get pending friend requests --- requests.sh | 1 + zeeguu/api/endpoints/friends.py | 111 +++++++++++++++++----------- zeeguu/core/model/friend.py | 18 ++++- zeeguu/core/model/friend_request.py | 21 ++++++ 4 files changed, 106 insertions(+), 45 deletions(-) create mode 100755 requests.sh diff --git a/requests.sh b/requests.sh new file mode 100755 index 000000000..3c2bfc81a --- /dev/null +++ b/requests.sh @@ -0,0 +1 @@ +curl -X GET "localhost:8080/users/discover/2/happy" \ No newline at end of file diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 51fd72279..455882401 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -40,6 +40,19 @@ def get_friend_requests(): result = [_serialize_friend_request(req) for req in friendRequest] return json_result(result) +@api.route("/get_pending_friend_requests", methods=["GET"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def get_pending_friend_requests(): + """ + Get all pending friend requests of a user + """ + + friendRequest = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) + # --------------------------------------------------------------------------- @api.route("/send_friend_request", methods=["POST"]) @@ -51,15 +64,20 @@ def send_friend_request(): Send a friend request from sender (current user with flask.g.user_id) to receiver """ sender_id = flask.g.user_id - receiver_id = request.form.get("receiver_id", type=int) + receiver_id = request.json.get("receiver_id") status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: flask.abort(status_code, error_message) - friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) - response = _serialize_friend_request(friend_request) - return json_result(response) + try: + friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) + response = _serialize_friend_request(friend_request) + return json_result(response) + except ValueError as e: + return flask.abort(400, str(e)) + except NoResultFound: + return flask.abort(404, "User not found") # --------------------------------------------------------------------------- @@ -72,7 +90,7 @@ def delete_friend_request(): Delete a friend request between sender and receiver """ sender_id = flask.g.user_id - receiver_id = request.form.get("receiver_id", type=int) + receiver_id = request.json.get("receiver_id") status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: @@ -92,8 +110,8 @@ def accept_friend_request(): """ # current user is the receiver of the friend request receiver_id = flask.g.user_id - sender_id = request.form.get("sender_id", type=int) - + 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: return flask.abort(status_code, error) @@ -116,7 +134,7 @@ def reject_friend_request(): """ # current user is the receiver of the friend request receiver_id = flask.g.user_id - sender_id = request.form.get("sender_id", type=int) + sender_id = request.json.get("sender_id") status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: @@ -135,7 +153,7 @@ def unfriend(): unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database """ sender_id = flask.g.user_id - receiver_id = request.form.get("receiver_id", type=int) + receiver_id = request.json.get("receiver_id") status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: @@ -186,40 +204,47 @@ def search_friends(username): # Helper functions below # --------------------------- -def _serialize_friend_request(friend_request: FriendRequest): - result = { - "id": friend_request.id, - "sender_id": friend_request.sender_id, - "receiver_id": friend_request.receiver_id, - "created_at": friend_request.created_at, - "reponded_at": friend_request.responded_at, - "status": friend_request.status, - } - return result - - -# def _serialize_friend_request(fr: FriendRequest): -# """ -# Serialize a FriendRequest object into JSON-friendly dict. - -# Args: -# fr (FriendRequest): The friend request object -# current_user_id (int): Optional, to simplify sender/receiver info - -# Returns: -# dict: JSON-serializable dictionary -# """ -# return { -# "id": fr.id, -# "sender": { -# "id": fr.sender.id, # This is the user_id is that nesessary? -# "name": fr.sender.name, # This will be updated to username -# "email": fr.sender.email, # Is this relevant? -# }, -# "status": fr.status, -# "created_at": fr.created_at.isoformat() if fr.created_at else None, -# "responded_at": fr.responded_at.isoformat() if fr.responded_at else None, -# } +# def _serialize_friend_request(friend_request: FriendRequest): +# result = { +# "id": friend_request.id, +# "sender_id": friend_request.sender_id, +# "receiver_id": friend_request.receiver_id, +# "created_at": friend_request.created_at, +# "reponded_at": friend_request.responded_at, +# "status": friend_request.status, +# } +# return result + + +def _serialize_friend_request(fr: FriendRequest): + """ + Serialize a FriendRequest object into JSON-friendly dict. + + Args: + fr (FriendRequest): The friend request object + current_user_id (int): Optional, to simplify sender/receiver info + + Returns: + dict: JSON-serializable dictionary + """ + return { + "id": fr.id, + "sender": { + "id": fr.sender.id, # This is the user_id is that nesessary? + "name": fr.sender.name, # This will be updated to username + "username": fr.sender.username, # This will be updated to username + "email": fr.sender.email, # Is this relevant? + }, + "receiver": { + "id": fr.receiver.id, # This is the user_id is that nesessary? + "name": fr.receiver.name, # This will be updated to username + "username": fr.receiver.username, # This will be updated to username + "email": fr.receiver.email, # Is this relevant? + }, + "status": fr.status, + "created_at": fr.created_at.isoformat() if fr.created_at else None, + "responded_at": fr.responded_at.isoformat() if fr.responded_at else None, + } def _serialize_friendship(friendship: Friend, status: str = "accepted"): result = { diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index cca3d4e51..27c348d47 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -25,7 +25,6 @@ class Friend(db.Model): 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.""" @@ -59,6 +58,8 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: @staticmethod def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: + from zeeguu.core.model.friend_request import FriendRequest + term = term.strip().lower() # Subquery: users already connected (either direction) @@ -82,11 +83,24 @@ def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> Friend.friend_id == current_user_id ) ) + # Query for users that already have a pending friend request with the current user + pending_request_ids = db.session.query( + FriendRequest.sender_id + ).filter( + FriendRequest.receiver_id == current_user_id, + FriendRequest.status == "pending" + ).union( + db.session.query(FriendRequest.receiver_id).filter( + FriendRequest.sender_id == current_user_id, + FriendRequest.status == "pending" + ) + ) query = User.query.filter( func.lower(User.username).like(f"%{term}%"), # search User.id != current_user_id, # exclude self - ~User.id.in_(friend_ids_subquery) # exclude existing friends + ~User.id.in_(friend_ids_subquery), # exclude existing friends + ~User.id.in_(pending_request_ids) # exclude users with pending friend requests ).order_by(User.username).limit(limit) return query.all() diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index c27928206..be1e1360e 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -113,6 +113,27 @@ def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): ) return requests + @classmethod + def get_pending_friend_requests_for_user(cls, user_id: int): + """ + Get pending friend requests received by a user. + + Args: + cls (FriendRequest): The FriendRequest class + user_id (int): ID of the user + + Returns: + List[FriendRequest]: List of pending friend request objects + """ + requests = ( + db.session.query(cls) + .filter(FriendRequest.sender_id == user_id) + .filter(FriendRequest.status == "pending") + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests + @classmethod def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: """Delete a friend request between sender and receiver.""" From fb0c9e8e07b30907e6fa6eb70b88df79019e9e1e Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 3 Mar 2026 16:57:56 +0100 Subject: [PATCH 034/142] dont double json --- zeeguu/api/endpoints/friends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 455882401..745d244d0 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -254,7 +254,7 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): "created_at": friendship.created_at, "status": status, } - return json_result(result) + return result def _serialize_user(user: User): return { From 3d77552ca0a5ae84636109d06175286ffcbad92a Mon Sep 17 00:00:00 2001 From: gabortodor Date: Tue, 3 Mar 2026 18:47:12 +0100 Subject: [PATCH 035/142] Minor changes --- zeeguu/api/endpoints/badges.py | 100 ++++++++++++------ .../audio_lessons/daily_lesson_generator.py | 7 +- zeeguu/core/badges/badge_progress.py | 20 ++-- zeeguu/core/model/badge.py | 18 +++- zeeguu/core/model/badge_level.py | 34 +++++- zeeguu/core/model/daily_audio_lesson.py | 7 ++ zeeguu/core/model/user.py | 4 +- zeeguu/core/model/user_article.py | 3 + zeeguu/core/model/user_badge_level.py | 47 ++++---- zeeguu/core/model/user_word.py | 4 +- 10 files changed, 161 insertions(+), 83 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 41738c914..17a9946c0 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -1,7 +1,7 @@ import flask from sqlalchemy.orm import joinedload -from zeeguu.core.model import User +from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from zeeguu.core.model.badge import Badge @@ -10,51 +10,85 @@ # --------------------------------------------------------------------------- -@api.route("/badges/count_not_shown_badges", methods=["GET"]) +@api.route("/count_not_shown_badges", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session def get_not_shown_badge_levels_for_user(): - user = User.find_by_id(flask.g.user_id) - return json_result(UserBadgeLevel.count_user_not_shown(user.id)) + """ + Return the number of user badge levels that the current user has achieved + but have not yet been shown to them. + """ + return json_result(UserBadgeLevel.count_user_not_shown(flask.g.user_id)) # --------------------------------------------------------------------------- -@api.route("/badges/", methods=["GET"]) +@api.route("/get_user_badges", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_badges_for_user(user_id: int): - # Get all badge levels achieved by the user - badges = ( - Badge.query - .options(joinedload(Badge.badge_levels)) - .all() - ) +def get_badges_for_user(): + """ + Retrieve all badges and their levels for the current user. + Each badge level includes achievement status and whether it has been shown. + + Returns: + [ + { + "badge_id": 1, + "name": "Meaning Builder", + "description": "Translate {target_value} words while reading.", + "levels": [ + { + "badge_level": 1, + "target_value": 50, + "icon_url": "/icons/badge1.png", + "achieved": true, + "achieved_at": "2026-03-03T12:34:56", + "is_shown": false, + "name": "Beginner" + }, ...] + }, ... ] + """ + user_id = flask.g.user_id + + badges = Badge.query.options(joinedload(Badge.badge_levels)).all() user_badge_levels = UserBadgeLevel.find_all(user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} - result = [] - for badge in badges: - badge_levels = [] - for badge_level in sorted(badge.badge_levels, key=lambda b: b.level): - achieved = badge_level.id in achieved_map - achieved_at = achieved_map[badge_level.id].achieved_at if achieved else None - badge_levels.append({ - "badge_level": badge_level.level, - "target_value": badge_level.target_value, - "icon_url": badge_level.icon_url, - "achieved": achieved, - "achieved_at": achieved_at.isoformat() if achieved_at else None, - "is_shown": achieved_map[badge_level.id].is_shown if achieved else False, - "name": badge_level.name - }) - result.append({ - "badge_id": badge.id, - "name": badge.name, - "description": badge.description, - "levels": badge_levels, - }) + + result = [serialize_badge(badge, achieved_map) for badge in badges] UserBadgeLevel.update_not_shown_for_user(db_session, user_id) db_session.commit() + return json_result(result) + + +def serialize_badge(badge: Badge, achieved_map: dict) -> dict: + levels = [ + serialize_badge_level(level, achieved_map.get(level.id)) + for level in sorted(badge.badge_levels, key=lambda b: b.level) + ] + + return { + "badge_id": badge.id, + "name": badge.name, + "description": badge.description, + "levels": levels, + } + + +def serialize_badge_level(level: BadgeLevel, user_level: UserBadgeLevel | None) -> dict: + return { + "badge_level": level.level, + "target_value": level.target_value, + "icon_url": level.icon_url, + "achieved": user_level is not None, + "achieved_at": ( + user_level.achieved_at.isoformat() + if user_level and user_level.achieved_at + else None + ), + "is_shown": user_level.is_shown if user_level else False, + "name": level.name, + } diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index 7b1e00f11..40970824b 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -746,12 +746,7 @@ def update_lesson_state_for_user(self, user, lesson_id, state_data): self._send_lesson_completion_notification(lesson, user) from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels - completed_lessons = ( - DailyAudioLesson.query - .filter_by(user_id=user.id) - .filter(DailyAudioLesson.completed_at.isnot(None)) - .count() - ) + completed_lessons = DailyAudioLesson.find_user_completed_lesson_count(user.id) update_badge_levels(db.session, BadgeCode.COMPLETED_AUDIO_LESSONS, user.id, completed_lessons) else: diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py index e96c64524..cae71c162 100644 --- a/zeeguu/core/badges/badge_progress.py +++ b/zeeguu/core/badges/badge_progress.py @@ -5,9 +5,16 @@ def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current_value: int) -> list[UserBadgeLevel]: """ - Award all achievable badge levels a user doesn't have yet for a specific badge. + Award all badge levels for a given badge_code that the user qualifies for but has not yet earned. - Returns only newly created UserBadgeLevel objects. + Parameters: + - db_session: SQLAlchemy session + - badge_code: Enum identifying the badge + - user_id: ID of the user + - current_value: User's current value for the metric associated with the badge + + Returns: + - List of newly created UserBadgeLevel objects (empty if none were awarded) """ badge = Badge.find(badge_code) if not badge: @@ -29,11 +36,10 @@ def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current owned_ids = {lvl.badge_level_id for lvl in existing_levels} missing_ids = [lvl_id for lvl_id in badge_level_ids if lvl_id not in owned_ids] - created_badges: list[UserBadgeLevel] = [] - for level_id in missing_ids: - new_badge = UserBadgeLevel(user_id=user_id, badge_level_id=level_id) - db_session.add(new_badge) - created_badges.append(new_badge) + created_badges = [UserBadgeLevel(user_id=user_id, badge_level_id=level_id) + for level_id in missing_ids] + + db_session.add_all(created_badges) return created_badges diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index c437efd4d..448d8934e 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -3,15 +3,22 @@ from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.db import db + class BadgeCode(enum.Enum): + """Enum representing all available badge codes in the system.""" TRANSLATED_WORDS = 'TRANSLATED_WORDS' - CORRECT_EXERCISES= 'CORRECT_EXERCISES' + CORRECT_EXERCISES = 'CORRECT_EXERCISES' COMPLETED_AUDIO_LESSONS = 'COMPLETED_AUDIO_LESSONS' STREAK_COUNT = 'STREAK_COUNT' LEARNED_WORDS = 'LEARNED_WORDS' READ_ARTICLES = 'READ_ARTICLES' + class Badge(db.Model): + """ + Represents a badge that can be earned by users. Each badge can have + multiple levels defined in BadgeLevel. + """ __tablename__ = "badge" id = db.Column(db.Integer, primary_key=True) @@ -20,12 +27,10 @@ class Badge(db.Model): description = db.Column(db.Text) is_hidden = db.Column(db.Boolean, nullable=False, default=False) - # Constraints __table_args__ = ( db.UniqueConstraint("code"), ) - # Relationships badge_levels = db.relationship(BadgeLevel, back_populates="badge", cascade="all, delete-orphan") def __repr__(self): @@ -33,5 +38,10 @@ def __repr__(self): @classmethod def find(cls, code: BadgeCode) -> "Badge": - """Find badge for a specific code""" + """ + Find a badge by its BadgeCode enum value. + + Returns: + Badge object if found, else None. + """ return cls.query.filter_by(code=code).first() diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 2585202ae..69ebab0f6 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -2,6 +2,10 @@ class BadgeLevel(db.Model): + """ + Represents a level of a badge. Each badge can have multiple levels, each + with a target value and optional icon. Levels are unique per badge. + """ __tablename__ = "badge_level" id = db.Column(db.Integer, primary_key=True) @@ -11,21 +15,32 @@ class BadgeLevel(db.Model): target_value = db.Column(db.Integer, nullable=False) icon_url = db.Column(db.String(255)) - # Constraints __table_args__ = ( db.UniqueConstraint("badge_id", "level"), ) - # Relationships badge = db.relationship("Badge", back_populates="badge_levels") - user_badge_levels = db.relationship("UserBadgeLevel", back_populates="badge_level", cascade="all, delete-orphan") + user_badge_levels = db.relationship( + "UserBadgeLevel", + back_populates="badge_level", + cascade="all, delete-orphan" + ) def __repr__(self): return f"" @classmethod def find_all_achievable(cls, badge_id: int, current_value: int) -> list["BadgeLevel"]: - """Find all badge levels for a specific badge id that are achievable""" + """ + Find all badge levels for a specific badge that the user can achieve given their current value. + + Args: + badge_id: The ID of the badge. + current_value: The user's current value for the badge metric. + + Returns: + List of BadgeLevel objects that are achievable. + """ return cls.query.filter( cls.badge_id == badge_id, cls.target_value <= current_value @@ -33,5 +48,14 @@ def find_all_achievable(cls, badge_id: int, current_value: int) -> list["BadgeLe @classmethod def find(cls, badge_id: int, level: int) -> "BadgeLevel | None": - """Find badge level for a specific badge id and level""" + """ + Find a specific badge level by badge ID and level number. + + Args: + badge_id: The ID of the badge. + level: The level number. + + Returns: + BadgeLevel object if found, else None. + """ return cls.query.filter_by(badge_id=badge_id, level=level).first() diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index 5104a9180..d1aca0722 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -151,3 +151,10 @@ def find_latest_for_user(cls, user, include_completed=False): if not include_completed: query = query.filter(cls.completed_at.is_(None)) return query.order_by(cls.recommended_at.desc()).first() + + @classmethod + def find_user_completed_lesson_count(cls, user_id): + """Returns the number of completed audio lessons for a specific user.""" + return (cls.query.filter_by(user_id=user_id) + .filter(DailyAudioLesson.completed_at.isnot(None)).count()) + diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 158a39d16..c6ebad0e0 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -460,9 +460,7 @@ def isTeacher(self): return False def correct_exercises_completed(self): - """ - Returns the total number of correct exercises for a user. - """ + """Returns the total number of correct exercises for a user.""" from zeeguu.core.model.user_word import UserWord from zeeguu.core.model.exercise import Exercise from zeeguu.core.model import ExerciseOutcome diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index 8aa7aa73b..eb5b01220 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -686,6 +686,9 @@ def article_infos(cls, user, articles, select_appropriate=True): @classmethod def get_completed_article_count_by_user(cls, user_id): + """ + Returns the number of completed articles for a specific user. + """ return ( cls.query .filter_by(user_id=user_id) diff --git a/zeeguu/core/model/user_badge_level.py b/zeeguu/core/model/user_badge_level.py index b61d01604..228f3b0af 100644 --- a/zeeguu/core/model/user_badge_level.py +++ b/zeeguu/core/model/user_badge_level.py @@ -1,26 +1,25 @@ from datetime import datetime -import sqlalchemy -from sqlalchemy.orm import relationship - from zeeguu.core.model.db import db class UserBadgeLevel(db.Model): + """ + Represents the association between a user and a badge level they have achieved. + Tracks when the badge level was achieved and whether it has been shown to the user. + """ __tablename__ = "user_badge_level" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) badge_level_id = db.Column(db.Integer, db.ForeignKey("badge_level.id"), nullable=False) - achieved_at = db.Column(db.DateTime, default=None) + achieved_at = db.Column(db.DateTime, default=datetime.now) is_shown = db.Column(db.Boolean, default=False) - # Constraints __table_args__ = ( db.UniqueConstraint("user_id", "badge_level_id"), ) - # Relationships badge_level = db.relationship("BadgeLevel", back_populates="user_badge_levels") user = db.relationship("User") @@ -34,38 +33,38 @@ def __init__( self.user_id = user_id self.badge_level_id = badge_level_id self.is_shown = is_shown - if achieved_at is None: - self.achieved_at = datetime.now() - else: - self.achieved_at = achieved_at + self.achieved_at = achieved_at or datetime.now() def __repr__(self): return f"" @classmethod - def find_all(cls, user_id: int): - """Find existing user badge levels by user id.""" + def find_all(cls, user_id: int) -> list["UserBadgeLevel"]: + """Return all badge levels achieved by a user.""" return cls.query.filter_by(user_id=user_id).all() @classmethod - def count_user_not_shown(cls, user_id: int): - """Find existing not shown user badge levels by user id.""" + def count_user_not_shown(cls, user_id: int) -> int: + """Return the count of badge levels that the user has not seen yet.""" return cls.query.filter_by(user_id=user_id, is_shown=False).count() @classmethod - def find(cls, user_id: int, badge_level_ids: list[int]): - """Find user badge levels for a specific user_id and a list of badge_level_ids.""" + def find(cls, user_id: int, badge_level_ids: list[int]) -> list["UserBadgeLevel"]: + """Return user badge levels for the specified badge_level_ids.""" if not badge_level_ids: return [] return cls.query.filter(cls.user_id == user_id, cls.badge_level_id.in_(badge_level_ids)).all() @classmethod def update_not_shown_for_user(cls, session, user_id: int): - """Updated not shown user badge levels for a specific user_id.""" - badge_level = cls.query.filter_by(user_id=user_id, is_shown=False).all() - for badge_level in badge_level: - badge_level.is_shown = True - session.add(badge_level) + """ + Mark all badge levels for a user as shown. + Does not commit. + """ + unseen_levels = cls.query.filter_by(user_id=user_id, is_shown=False).all() + for level in unseen_levels: + level.is_shown = True + session.add(level) @classmethod def create( @@ -75,7 +74,11 @@ def create( badge_level_id: int, achieved_at: datetime = None, is_shown: bool = False, - ): + ) -> "UserBadgeLevel": + """ + Create a new UserBadgeLevel record for a user and badge level. + Does not commit. + """ new = cls(user_id, badge_level_id, achieved_at, is_shown) session.add(new) return new diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index 74f348d54..ad94bbff8 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -410,9 +410,7 @@ def report_exercise_outcome( @classmethod def find_user_learned_words_count(cls, user_id): - """ - Finds the number of learned words for a specific user. - """ + """Returns the number of learned words for a specific user.""" return cls.query.filter_by(user_id=user_id).filter(UserWord.learned_time.isnot(None)).count() @classmethod From 11f1b6aad232e59928a3d747cf4bd3d51fec4da4 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 11:35:33 +0100 Subject: [PATCH 036/142] search users --- zeeguu/api/endpoints/friends.py | 9 +++-- zeeguu/core/model/friend.py | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 745d244d0..d66104733 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -168,7 +168,7 @@ def unfriend(): # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- +# --------------------------------------------------------------------- ------ @api.route("/discover_friends/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @@ -187,7 +187,12 @@ def discover_by_username(username): @cross_domain @requires_session def search_by_username(username): - return flask.abort(501, "Not implemented yet") + + if not username or username.strip() == "": + return flask.abort(400, "Username cannot be empty") + + result = Friend.search_users(flask.g.user_id, username) + return json_result(result) # --------------------------------------------------------------------------- @api.route("/search_friends/", methods=["GET"]) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 27c348d47..1e44dccb0 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -5,6 +5,7 @@ class Friend(db.Model): + __tablename__ = "friends" __table_args__ = {"mysql_collate": "utf8_bin"} @@ -56,6 +57,67 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: return False + + @staticmethod + def search_users(current_user_id: int, term: str, email: str = None, limit: int = 20): + """ + Search users by username (partial match) or exact email. + For each user, return: + - user info + - friend request status (if any) + - friendship status (if any) + """ + from zeeguu.core.model.friend_request import FriendRequest + term = term.strip().lower() if term else None + email = email.strip().lower() if email else None + + # Build base query + query = User.query + filters = [] + if term: + filters.append(func.lower(User.username).like(f"%{term}%")) + if email: + filters.append(func.lower(User.email) == email) + if not filters: + return [] # nothing to search + + query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) + + results = [] + for user in query.all(): + # Friendship status + friendship = Friend.query.filter( + ((Friend.user_id == current_user_id) & (Friend.friend_id == user.id)) | + ((Friend.user_id == user.id) & (Friend.friend_id == current_user_id)) + ).first() + + # Friend request status + friend_request = FriendRequest.query.filter( + ((FriendRequest.sender_id == current_user_id) & (FriendRequest.receiver_id == user.id)) | + ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) + ).order_by(FriendRequest.created_at.desc()).first() + + results.append({ + "user": { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + }, + "friendship": { + "id": friendship.id if friendship else None, + "created_at": friendship.created_at.isoformat() if friendship and friendship.created_at else None, + } if friendship else None, + "friend_request": { + "id": friend_request.id if friend_request else None, + "sender_id": friend_request.sender_id if friend_request else None, + "receiver_id": friend_request.receiver_id if friend_request else None, + "status": friend_request.status if friend_request else None, + "created_at": friend_request.created_at.isoformat() if friend_request and friend_request.created_at else None, + } if friend_request else None + }) + return results + @staticmethod def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: from zeeguu.core.model.friend_request import FriendRequest From 07ed16c1b4a40b93b09b6f4421a8d1a3008aad7d Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 17:26:02 +0100 Subject: [PATCH 037/142] More usernames to generate --- zeeguu/core/model/user.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 7315b8965..c062813fc 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -68,6 +68,7 @@ def __init__( email, name, password, + username=None, learned_language=None, native_language=None, invitation_code=None, @@ -78,7 +79,7 @@ def __init__( self.email = email self.name = name # The name of the user - self.username = self.generate_username() # Username is custom name for display + self.username = username or self.generate_username() # Username is custom name to display in UI self.update_password(password) self.learned_language = learned_language or Language.default_learned() self.native_language = native_language or Language.default_native_language() @@ -88,20 +89,33 @@ def __init__( self.creation_platform = creation_platform ADJECTIVES = [ - "brave", "clever", "curious", "silent", "rapid", - "happy", "bright", "nordic", "bold", "calm" + "brave", "clever", "curious", "silent", "rapid", + "happy", "bright", "playful", "bold", "calm", + "gentle", "keen", "witty", "daring", "serene", + "lively", "mighty", "patient", "vivid", "wise" ] NOUNS = [ - "otter", "falcon", "wolf", "learner", - "linguist", "explorer", "reader", "thinker" - ] + "otter", "falcon", "wolf", "fox", "owl", "panther", + "lion", "tiger", "bear", "eagle", "rabbit", "deer", + "leopard", "cheetah", "badger", "beaver", "lynx", "moose" + ] + + MAX_NUMBER_USERNAME = 9999 @classmethod def generate_username(cls): + """ + :summary: + + Generate a random username in the format 'adjective_noun1234' + Can currently generate 20 x 15 x 9999 = 2,999,700 unique usernames + + :return: A string username + """ adjective = random.choice(cls.ADJECTIVES) noun = random.choice(cls.NOUNS) - number = random.randint(1, 999) + number = random.randint(1, cls.MAX_NUMBER_USERNAME) return f"{adjective}_{noun}{number}" From 505cc5325e02f5c80508fe75c8d3f6977ba859ce Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 17:26:24 +0100 Subject: [PATCH 038/142] ilike for case insensitive matching --- zeeguu/api/endpoints/friends.py | 6 +++--- zeeguu/core/model/friend.py | 19 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index d66104733..2f0d256c3 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -23,7 +23,7 @@ def get_friends(): Get all friends of current user with flask.g.user_id """ friends = Friend.get_friends(flask.g.user_id) - return json_result(_seralize_users(friends)) + return json_result(_serialize_users(friends)) # --------------------------------------------------------------------------- @@ -179,7 +179,7 @@ def discover_by_username(username): """ user_id = flask.g.user_id new_friends = Friend.search_for_new_friends(user_id, username) - return json_result(_seralize_users(new_friends)) + return json_result(_serialize_users(new_friends)) # --------------------------------------------------------------------------- @api.route("/search_users/", methods=["GET"]) @@ -269,7 +269,7 @@ def _serialize_user(user: User): "email": user.email, } -def _seralize_users(users: list[User]): +def _serialize_users(users: list[User]): return [_serialize_user(user) for user in users] diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 1e44dccb0..b68903022 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -59,7 +59,7 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: @staticmethod - def search_users(current_user_id: int, term: str, email: str = None, limit: int = 20): + def search_users(current_user_id: int, term: str, limit: int = 20): """ Search users by username (partial match) or exact email. For each user, return: @@ -68,19 +68,18 @@ def search_users(current_user_id: int, term: str, email: str = None, limit: int - friendship status (if any) """ from zeeguu.core.model.friend_request import FriendRequest - term = term.strip().lower() if term else None - email = email.strip().lower() if email else None - + # Build base query - query = User.query filters = [] if term: - filters.append(func.lower(User.username).like(f"%{term}%")) - if email: - filters.append(func.lower(User.email) == email) + filters.append(func.lower(User.username).ilike(f"%{term}%")) # ilike for case-insensitive partial match + filters.append(func.lower(User.email) == term) # exact match for email + filters.append(func.lower(User.name) == term) # exact match for name + if not filters: return [] # nothing to search + query = User.query query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) results = [] @@ -122,8 +121,6 @@ def search_users(current_user_id: int, term: str, email: str = None, limit: int def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: from zeeguu.core.model.friend_request import FriendRequest - term = term.strip().lower() - # Subquery: users already connected (either direction) # existing_friends = db.session.query(Friend.user_id, Friend.friend_id).filter( # or_( @@ -159,7 +156,7 @@ def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> ) query = User.query.filter( - func.lower(User.username).like(f"%{term}%"), # search + func.lower(User.username).ilike(f"%{term}%"), # search User.id != current_user_id, # exclude self ~User.id.in_(friend_ids_subquery), # exclude existing friends ~User.id.in_(pending_request_ids) # exclude users with pending friend requests From 6ca740a036416bef1ac5735ec26e2678a98422d1 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 17:30:31 +0100 Subject: [PATCH 039/142] Using the function from user to generate a username instead and updated comment --- tools/migrations/26-02-24-b-add_username.py | 34 +++++-------------- .../migrations/26-02-24-friendship_system.sql | 2 +- zeeguu/core/model/user.py | 2 +- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/tools/migrations/26-02-24-b-add_username.py b/tools/migrations/26-02-24-b-add_username.py index 80456833e..7957fb09d 100644 --- a/tools/migrations/26-02-24-b-add_username.py +++ b/tools/migrations/26-02-24-b-add_username.py @@ -1,11 +1,12 @@ #!/usr/bin/env python """ -Migration script to backfill reading_session_id on existing bookmarks. +Migration script to automatically populate usernames for existing users. -For each bookmark without a reading_session_id, finds the reading session where: -- Same user -- Same article -- Bookmark creation time falls within the reading session's time window +The usernames are generated in the format 'adjective_noun1234' (e.g., 'brave_tiger5678') +and are guaranteed to be unique across the user base. +This script should be run after the database schema has been updated to include the new 'username' column in the 'users' table. + +26-02-24-a-add_username.sql should have been run first to add the 'username' column to the 'user' table. Run with: source ~/.venvs/z_env/bin/activate && python tools/migrations/26-02-24-b-add_username.py """ @@ -16,36 +17,17 @@ from zeeguu.core.model.user import User from zeeguu.core.model import db -import random - -ADJECTIVES = [ - "brave", "clever", "curious", "silent", "rapid", - "happy", "bright", "nordic", "bold", "calm" -] - -NOUNS = [ - "otter", "falcon", "wolf", "learner", - "linguist", "explorer", "reader", "thinker" -] - - -def generate_username(): - adjective = random.choice(ADJECTIVES) - noun = random.choice(NOUNS) - number = random.randint(1, 999) - - return f"{adjective}_{noun}{number}" def generate_unique_username(): while True: - username = generate_username() + username = User.generate_username() exists = User.query.filter_by(username=username).first() if not exists: return username def populate_usernames(): - users = User.query.all() + users : list[User] = User.query.all() for user in users: user.username = generate_unique_username() db.session.commit() diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index 08cae5566..e7b581b71 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -1,4 +1,4 @@ --- Denormalized friends table +-- friends table CREATE TABLE friends ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index c062813fc..d4eb560fa 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -108,7 +108,7 @@ def generate_username(cls): """ :summary: - Generate a random username in the format 'adjective_noun1234' + Generate a random username in the format 'adjective_noun1234' Can currently generate 20 x 15 x 9999 = 2,999,700 unique usernames :return: A string username From c8c138e8825b925a6be2ade1b0c785e5fab44de6 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 17:30:51 +0100 Subject: [PATCH 040/142] user table instead of users --- tools/migrations/26-02-24-b-add_username.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/migrations/26-02-24-b-add_username.py b/tools/migrations/26-02-24-b-add_username.py index 7957fb09d..dff2c4db9 100644 --- a/tools/migrations/26-02-24-b-add_username.py +++ b/tools/migrations/26-02-24-b-add_username.py @@ -4,7 +4,7 @@ The usernames are generated in the format 'adjective_noun1234' (e.g., 'brave_tiger5678') and are guaranteed to be unique across the user base. -This script should be run after the database schema has been updated to include the new 'username' column in the 'users' table. +This script should be run after the database schema has been updated to include the new 'username' column in the 'user' table. 26-02-24-a-add_username.sql should have been run first to add the 'username' column to the 'user' table. From 6ebdc6281570a614de7f156de76ea5ee7001ee10 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 17:35:58 +0100 Subject: [PATCH 041/142] updated the comment in generate username to reflect the correct number of unique usernames --- zeeguu/core/model/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index d4eb560fa..c7fefe9b4 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -109,7 +109,7 @@ def generate_username(cls): :summary: Generate a random username in the format 'adjective_noun1234' - Can currently generate 20 x 15 x 9999 = 2,999,700 unique usernames + Can currently generate 20 x 18 x 9999 = 3,598,200 unique usernames :return: A string username """ From 2ab65879baaf6d6bc5789c72824e58a5660dd53c Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 4 Mar 2026 18:18:05 +0100 Subject: [PATCH 042/142] Added badge progression tracking --- ...6-03-04--add_user_badge_progress_table.sql | 11 ++ zeeguu/api/endpoints/activity_tracking.py | 5 +- zeeguu/api/endpoints/badges.py | 36 ++++++- zeeguu/api/endpoints/translation.py | 5 +- .../audio_lessons/daily_lesson_generator.py | 5 +- zeeguu/core/badges/badge_progress.py | 77 ++++++++++---- zeeguu/core/model/daily_audio_lesson.py | 8 +- zeeguu/core/model/user.py | 16 --- zeeguu/core/model/user_article.py | 12 --- zeeguu/core/model/user_badge_progress.py | 100 ++++++++++++++++++ zeeguu/core/model/user_language.py | 5 +- zeeguu/core/model/user_word.py | 10 +- .../basicSR/four_levels_per_word.py | 4 +- 13 files changed, 213 insertions(+), 81 deletions(-) create mode 100644 tools/migrations/26-03-04--add_user_badge_progress_table.sql create mode 100644 zeeguu/core/model/user_badge_progress.py diff --git a/tools/migrations/26-03-04--add_user_badge_progress_table.sql b/tools/migrations/26-03-04--add_user_badge_progress_table.sql new file mode 100644 index 000000000..37c811794 --- /dev/null +++ b/tools/migrations/26-03-04--add_user_badge_progress_table.sql @@ -0,0 +1,11 @@ +-- tools/migrations/26-03-04--add_user_badge_progress_table.sql + +CREATE TABLE user_badge_progress ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + badge_id INT NOT NULL, + current_value INT NOT NULL, + UNIQUE(user_id, badge_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (badge_id) REFERENCES badge(id) +); \ No newline at end of file diff --git a/zeeguu/api/endpoints/activity_tracking.py b/zeeguu/api/endpoints/activity_tracking.py index 7a39cf826..34b4edb89 100644 --- a/zeeguu/api/endpoints/activity_tracking.py +++ b/zeeguu/api/endpoints/activity_tracking.py @@ -2,7 +2,7 @@ from flask import request from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode +from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode from zeeguu.core.model import UserActivityData, User from zeeguu.core.user_activity_hooks.article_interaction_hooks import ( distill_article_interactions, @@ -137,8 +137,7 @@ def _check_and_notify_article_completion_on_scroll(user, form_data): user_article.completed_at = datetime.now() # Update READ_ARTICLES badge progress - current_read_article_count = UserArticle.get_completed_article_count_by_user(user.id) - update_badge_levels(db_session, BadgeCode.READ_ARTICLES, user.id, current_read_article_count) + increment_badge_progress(db_session, BadgeCode.READ_ARTICLES, user.id) # Send notification if enabled from flask import current_app diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 17a9946c0..0f5e169ac 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.user_badge_progress import UserBadgeProgress from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session @@ -14,7 +15,7 @@ # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_not_shown_badge_levels_for_user(): +def get_not_shown_user_badge_levels(): """ Return the number of user badge levels that the current user has achieved but have not yet been shown to them. @@ -48,6 +49,7 @@ def get_badges_for_user(): "is_shown": false, "name": "Beginner" }, ...] + "current_value": 10 }, ... ] """ user_id = flask.g.user_id @@ -55,16 +57,39 @@ def get_badges_for_user(): badges = Badge.query.options(joinedload(Badge.badge_levels)).all() user_badge_levels = UserBadgeLevel.find_all(user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} + user_badge_progress = UserBadgeProgress.find_all(user_id) + progress_map = {ubp.badge_id: ubp for ubp in user_badge_progress} - result = [serialize_badge(badge, achieved_map) for badge in badges] + result = [serialize_badge(badge, achieved_map, progress_map) for badge in badges] - UserBadgeLevel.update_not_shown_for_user(db_session, user_id) + return json_result(result) + +# --------------------------------------------------------------------------- +@api.route("/update_not_shown_badges", methods=["POST"]) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def update_not_shown_user_badge_levels(): + """ + Mark all unseen badge levels for the current user as shown. + + This updates all UserBadgeLevel records where: + - user_id matches the current user + - is_shown is False + + Returns: + { + "updated": true + } + """ + UserBadgeLevel.update_not_shown_for_user(db_session, flask.g.user_id) db_session.commit() - return json_result(result) + return json_result({"updated": True}) -def serialize_badge(badge: Badge, achieved_map: dict) -> dict: +def serialize_badge(badge: Badge, achieved_map: dict, progress_map: dict) -> dict: + progress = progress_map.get(badge.id) levels = [ serialize_badge_level(level, achieved_map.get(level.id)) for level in sorted(badge.badge_levels, key=lambda b: b.level) @@ -75,6 +100,7 @@ def serialize_badge(badge: Badge, achieved_map: dict) -> dict: "name": badge.name, "description": badge.description, "levels": levels, + "current_value": progress.current_value if progress else 0, } diff --git a/zeeguu/api/endpoints/translation.py b/zeeguu/api/endpoints/translation.py index e3a364eeb..5fe242d5e 100644 --- a/zeeguu/api/endpoints/translation.py +++ b/zeeguu/api/endpoints/translation.py @@ -157,9 +157,8 @@ def get_one_translation(from_lang_code, to_lang_code): log( f"[TRANSLATION-TIMING] Bookmark.find_or_create completed in {bookmark_elapsed:.3f}s for word='{word_str}'" ) - from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode - current_bookmark_count = len(Bookmark.find_by_specific_user(user)) - update_badge_levels(db_session, BadgeCode.TRANSLATED_WORDS, user.id, current_bookmark_count) + from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode + increment_badge_progress(db_session, BadgeCode.TRANSLATED_WORDS, user.id) db_session.commit() return json_result( diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index 40970824b..15ef6c9c7 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -745,9 +745,8 @@ def update_lesson_state_for_user(self, user, lesson_id, state_data): # Send email notification for lesson completion self._send_lesson_completion_notification(lesson, user) - from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels - completed_lessons = DailyAudioLesson.find_user_completed_lesson_count(user.id) - update_badge_levels(db.session, BadgeCode.COMPLETED_AUDIO_LESSONS, user.id, completed_lessons) + from zeeguu.core.badges.badge_progress import BadgeCode, increment_badge_progress + increment_badge_progress(db.session, BadgeCode.COMPLETED_AUDIO_LESSONS, user.id) else: return { diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py index cae71c162..45e8ed2fa 100644 --- a/zeeguu/core/badges/badge_progress.py +++ b/zeeguu/core/badges/badge_progress.py @@ -1,29 +1,18 @@ +from zeeguu.core.model.user_badge_progress import UserBadgeProgress from zeeguu.core.model.badge import BadgeCode, Badge from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.user_badge_level import UserBadgeLevel -def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current_value: int) -> list[UserBadgeLevel]: +def _award_badge_levels(db_session, badge_id: int, user_id: int, current_value: int) -> list[UserBadgeLevel]: """ - Award all badge levels for a given badge_code that the user qualifies for but has not yet earned. - - Parameters: - - db_session: SQLAlchemy session - - badge_code: Enum identifying the badge - - user_id: ID of the user - - current_value: User's current value for the metric associated with the badge - - Returns: - - List of newly created UserBadgeLevel objects (empty if none were awarded) + Create UserBadgeLevel entries for all newly achieved levels. + Returns only newly created levels. """ - badge = Badge.find(badge_code) - if not badge: - return [] - badge_level_ids = db_session.scalars( db_session.query(BadgeLevel.id) .filter( - BadgeLevel.badge_id == badge.id, + BadgeLevel.badge_id == badge_id, BadgeLevel.target_value <= current_value ) .order_by(BadgeLevel.level.asc()) @@ -37,9 +26,61 @@ def update_badge_levels(db_session, badge_code: BadgeCode, user_id: int, current missing_ids = [lvl_id for lvl_id in badge_level_ids if lvl_id not in owned_ids] - created_badges = [UserBadgeLevel(user_id=user_id, badge_level_id=level_id) - for level_id in missing_ids] + created_badges = [ + UserBadgeLevel(user_id=user_id, badge_level_id=level_id) + for level_id in missing_ids + ] db_session.add_all(created_badges) return created_badges + + +def increment_badge_progress(db_session, badge_code: BadgeCode, user_id: int, increment_value: int = 1) \ + -> list[UserBadgeLevel]: + """ + Increment a user's badge progress and award newly achieved levels. + Returns newly created UserBadgeLevel records. + """ + badge = Badge.find(badge_code) + if not badge: + return [] + + progress = UserBadgeProgress.create_or_increment( + db_session, + user_id, + badge.id, + increment_value + ) + + return _award_badge_levels( + db_session, + badge.id, + user_id, + progress.current_value + ) + + +def update_badge_progress(db_session, badge_code: BadgeCode, user_id: int, current_value: int) \ + -> list[UserBadgeLevel]: + """ + Overwrite a user's badge progress and award newly achieved levels. + Returns newly created UserBadgeLevel records. + """ + badge = Badge.find(badge_code) + if not badge: + return [] + + progress = UserBadgeProgress.create_or_update( + db_session, + user_id, + badge.id, + current_value + ) + + return _award_badge_levels( + db_session, + badge.id, + user_id, + progress.current_value + ) diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index d1aca0722..333018956 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, Integer, Text, JSON, ForeignKey, TIMESTAMP +from sqlalchemy import Column, Integer, JSON, ForeignKey, TIMESTAMP from sqlalchemy.orm import relationship from zeeguu.core.model.db import db @@ -152,9 +152,3 @@ def find_latest_for_user(cls, user, include_completed=False): query = query.filter(cls.completed_at.is_(None)) return query.order_by(cls.recommended_at.desc()).first() - @classmethod - def find_user_completed_lesson_count(cls, user_id): - """Returns the number of completed audio lessons for a specific user.""" - return (cls.query.filter_by(user_id=user_id) - .filter(DailyAudioLesson.completed_at.isnot(None)).count()) - diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index c6ebad0e0..f023e264a 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -459,22 +459,6 @@ def isTeacher(self): return False - def correct_exercises_completed(self): - """Returns the total number of correct exercises for a user.""" - from zeeguu.core.model.user_word import UserWord - from zeeguu.core.model.exercise import Exercise - from zeeguu.core.model import ExerciseOutcome - - result = ( - db.session.query(Exercise) - .join(UserWord, Exercise.user_word_id == UserWord.id) - .join(ExerciseOutcome, Exercise.outcome_id == ExerciseOutcome.id) - .filter(UserWord.user_id == self.id) - .filter(ExerciseOutcome.outcome.in_(ExerciseOutcome.correct_outcomes)) - ).count() - - return result - @classmethod @sqlalchemy.orm.validates("email") def validate_email(cls, col, email): diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index eb5b01220..b3ed1a7f2 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -683,15 +683,3 @@ def article_infos(cls, user, articles, select_appropriate=True): cls.user_article_info(user, article, tokenization_cache=existing_caches.get(article.id)) for article in articles_to_process ] - - @classmethod - def get_completed_article_count_by_user(cls, user_id): - """ - Returns the number of completed articles for a specific user. - """ - return ( - cls.query - .filter_by(user_id=user_id) - .filter(cls.completed_at is not None) - .count() - ) diff --git a/zeeguu/core/model/user_badge_progress.py b/zeeguu/core/model/user_badge_progress.py new file mode 100644 index 000000000..c73d24940 --- /dev/null +++ b/zeeguu/core/model/user_badge_progress.py @@ -0,0 +1,100 @@ +from zeeguu.core.model.db import db + + +class UserBadgeProgress(db.Model): + """ + Tracks a user's progress toward a specific badge. + Stores the current metric value used to determine badge level eligibility. + """ + __tablename__ = "user_badge_progress" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) + current_value = db.Column(db.Integer, nullable=False, default=0) + + __table_args__ = ( + db.UniqueConstraint("user_id", "badge_id"), + ) + + badge = db.relationship("Badge") + user = db.relationship("User") + + def __init__( + self, + user_id, + badge_id, + current_value=0 + ): + self.user_id = user_id + self.badge_id = badge_id + self.current_value = current_value + + def __repr__(self): + return f"" + + @classmethod + def find_all(cls, user_id: int) -> list["UserBadgeProgress"]: + """Return all badge progress records for a user.""" + return cls.query.filter_by(user_id=user_id).all() + + @classmethod + def find(cls, user_id: int, badge_ids: list[int]) -> list["UserBadgeProgress"]: + """Return progress records for the given badge IDs.""" + if not badge_ids: + return [] + return cls.query.filter(cls.user_id == user_id, cls.badge_id.in_(badge_ids)).all() + + @classmethod + def _get_or_create( + cls, + session, + user_id: int, + badge_id: int, + ) -> "UserBadgeProgress": + """ + Internal helper to fetch existing progress or create a new one (value=0). + Does not commit. + """ + record = cls.query.filter_by( + user_id=user_id, + badge_id=badge_id + ).one_or_none() + + if not record: + record = cls(user_id=user_id, badge_id=badge_id, current_value=0) + session.add(record) + + return record + + @classmethod + def create_or_increment( + cls, + session, + user_id: int, + badge_id: int, + increment: int + ) -> "UserBadgeProgress": + """ + Increment current_value by the given amount. + """ + record = cls._get_or_create(session, user_id, badge_id) + record.current_value += increment + session.add(record) + return record + + @classmethod + def create_or_update( + cls, + session, + user_id: int, + badge_id: int, + value: int + ) -> "UserBadgeProgress": + """ + Overwrite current_value with a new value. + """ + record = cls._get_or_create(session, user_id, badge_id) + record.current_value = value + session.add(record) + return record diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 9fd8847a6..2ff782fee 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -137,7 +137,4 @@ def update_streak_if_needed(self, session=None): self.last_practiced = now if session: - session.add(self) - - from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels - update_badge_levels(session, BadgeCode.STREAK_COUNT, self.user_id, self.daily_streak) + session.add(self) \ No newline at end of file diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index ad94bbff8..bf40e644c 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -393,9 +393,8 @@ def report_exercise_outcome( db_session.add(exercise) if source.source != "DAILY_AUDIO_LESSON" and outcome.correct: - from zeeguu.core.badges.badge_progress import update_badge_levels, BadgeCode - correct_exercise_count = self.user.correct_exercises_completed() - update_badge_levels(db_session, BadgeCode.CORRECT_EXERCISES, self.user.id, correct_exercise_count) + from zeeguu.core.badges.badge_progress import increment_badge_progress, BadgeCode + increment_badge_progress(db_session, BadgeCode.CORRECT_EXERCISES, self.user.id) scheduler = self.get_scheduler() @@ -408,11 +407,6 @@ def report_exercise_outcome( # self.update_fit_for_study(db_session) # self.update_learned_status(db_session) - @classmethod - def find_user_learned_words_count(cls, user_id): - """Returns the number of learned words for a specific user.""" - return cls.query.filter_by(user_id=user_id).filter(UserWord.learned_time.isnot(None)).count() - @classmethod def find_or_create(cls, session, user, meaning, is_user_added=False): """ diff --git a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py index f6a7df98b..7b7309dd3 100644 --- a/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py +++ b/zeeguu/core/word_scheduling/basicSR/four_levels_per_word.py @@ -63,9 +63,9 @@ def update_schedule(self, db_session, correctness, exercise_time: datetime = Non else: self.set_meaning_as_learned(db_session) - from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_levels + from zeeguu.core.badges.badge_progress import BadgeCode, increment_badge_progress user_id = self.user_word.user.id - update_badge_levels(db_session, BadgeCode.LEARNED_WORDS, user_id, UserWord.find_user_learned_words_count(user_id)) + increment_badge_progress(db_session, BadgeCode.LEARNED_WORDS, user_id) db_session.commit() # we simply return because the self object will have been deleted inside of the above call return From f9e7e5258a978103c11f8ef140c45502f8f6efb0 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 20:46:01 +0100 Subject: [PATCH 043/142] added back in the "@classmethod" to create_anonymous function in user --- zeeguu/core/model/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index c7fefe9b4..35b4b507f 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -118,7 +118,7 @@ def generate_username(cls): number = random.randint(1, cls.MAX_NUMBER_USERNAME) return f"{adjective}_{noun}{number}" - + @classmethod def create_anonymous( cls, uuid, From 2e692e4d8be0712a56ee8a077d3cb42d3c4ae183 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 20:47:16 +0100 Subject: [PATCH 044/142] deleted requests.sh --- requests.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100755 requests.sh diff --git a/requests.sh b/requests.sh deleted file mode 100755 index 3c2bfc81a..000000000 --- a/requests.sh +++ /dev/null @@ -1 +0,0 @@ -curl -X GET "localhost:8080/users/discover/2/happy" \ No newline at end of file From e1e0b2008f168ac137c8864f93a2d5d2ce63c16a Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 21:02:47 +0100 Subject: [PATCH 045/142] Fixing indentation in friends.py --- zeeguu/api/endpoints/friends.py | 286 +++++++++++++++----------------- 1 file changed, 132 insertions(+), 154 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 2f0d256c3..ffa55322f 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -19,11 +19,11 @@ @cross_domain @requires_session def get_friends(): - """ - Get all friends of current user with flask.g.user_id - """ - friends = Friend.get_friends(flask.g.user_id) - return json_result(_serialize_users(friends)) + """ + Get all friends of current user with flask.g.user_id + """ + friends = Friend.get_friends(flask.g.user_id) + return json_result(_serialize_users(friends)) # --------------------------------------------------------------------------- @@ -32,26 +32,26 @@ def get_friends(): @cross_domain @requires_session def get_friend_requests(): - """ - Get all friend requests of a user - """ + """ + Get all friend requests of a user + """ - friendRequest = FriendRequest.get_friend_requests_for_user(flask.g.user_id) - result = [_serialize_friend_request(req) for req in friendRequest] - return json_result(result) + friendRequest = FriendRequest.get_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) @api.route("/get_pending_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session def get_pending_friend_requests(): - """ - Get all pending friend requests of a user - """ + """ + Get all pending friend requests of a user + """ - friendRequest = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) - result = [_serialize_friend_request(req) for req in friendRequest] - return json_result(result) + friendRequest = FriendRequest.get_pending_friend_requests_for_user(flask.g.user_id) + result = [_serialize_friend_request(req) for req in friendRequest] + return json_result(result) # --------------------------------------------------------------------------- @@ -60,24 +60,24 @@ def get_pending_friend_requests(): @cross_domain @requires_session def send_friend_request(): - """ - Send a friend request from sender (current user with flask.g.user_id) to receiver - """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - flask.abort(status_code, error_message) - - try: - friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) - response = _serialize_friend_request(friend_request) - return json_result(response) - except ValueError as e: - return flask.abort(400, str(e)) - except NoResultFound: - return flask.abort(404, "User not found") + """ + Send a friend request from sender (current user with flask.g.user_id) to receiver + """ + sender_id = flask.g.user_id + receiver_id = request.json.get("receiver_id") + + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + flask.abort(status_code, error_message) + + try: + friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) + response = _serialize_friend_request(friend_request) + return json_result(response) + except ValueError as e: + return flask.abort(400, str(e)) + except NoResultFound: + return flask.abort(404, "User not found") # --------------------------------------------------------------------------- @@ -86,18 +86,18 @@ def send_friend_request(): @cross_domain @requires_session def delete_friend_request(): - """ - Delete a friend request between sender and receiver - """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - return flask.abort(status_code, error) - - is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) - return json_result(str(is_deleted)) + """ + 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: + return flask.abort(status_code, error) + + is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) + return json_result(str(is_deleted)) # --------------------------------------------------------------------------- @api.route("/accept_friend_request", methods=["POST"]) @@ -105,23 +105,23 @@ def delete_friend_request(): @cross_domain @requires_session def accept_friend_request(): - """ - Accept a friend request between sender and receiver, and create a friendship - """ - # current user is the receiver of the friend request - receiver_id = flask.g.user_id - sender_id = request.json.get("sender_id") - print(f"sender_id: {sender_id}") - status_code, error = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - return flask.abort(status_code, error) - - friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) - if friendship is None: - return flask.abort(404, "No friend request found to accept") - - response = _serialize_friendship(friendship) - return json_result(response) + """ + 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: + return flask.abort(status_code, error) + + friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) + if friendship is None: + return flask.abort(404, "No friend request found to accept") + + response = _serialize_friendship(friendship) + return json_result(response) # --------------------------------------------------------------------------- @api.route("/reject_friend_request", methods=["POST"]) @@ -129,19 +129,19 @@ def accept_friend_request(): @cross_domain @requires_session def reject_friend_request(): - """ - Reject a friend request between sender and receiver, and delete the friend request record in the database - """ - # current user is the receiver of the friend request - receiver_id = flask.g.user_id - sender_id = request.json.get("sender_id") - status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) - - if status_code >= 400: - return flask.abort(status_code, error_message) - - is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) - return json_result(is_rejected) + """ + 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: + return flask.abort(status_code, error_message) + + is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) + return json_result(is_rejected) # --------------------------------------------------------------------------- @api.route("/unfriend", methods=["POST"]) @@ -149,18 +149,18 @@ def reject_friend_request(): @cross_domain @requires_session def unfriend(): - """ - unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database - """ - sender_id = flask.g.user_id - receiver_id = request.json.get("receiver_id") - - status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) - if status_code >= 400: - return flask.abort(status_code, error_message) - - is_removed = Friend.remove_friendship(sender_id, receiver_id) - return json_result(str(is_removed)) + """ + Unfriend a friendship between user1 and user2, and delete the friends row (friendship record) in the database + """ + sender_id = flask.g.user_id + receiver_id = request.json.get("receiver_id") + + status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) + if status_code >= 400: + return flask.abort(status_code, error_message) + + is_removed = Friend.remove_friendship(sender_id, receiver_id) + return json_result(str(is_removed)) # --------------------------------------------------------------------------- @@ -174,12 +174,12 @@ def unfriend(): @cross_domain @requires_session def discover_by_username(username): - """ - Search for new friends with of a user by user_id - """ - user_id = flask.g.user_id - new_friends = Friend.search_for_new_friends(user_id, username) - return json_result(_serialize_users(new_friends)) + """ + Search for new friends with of a user by user_id + """ + user_id = flask.g.user_id + new_friends = Friend.search_for_new_friends(user_id, username) + return json_result(_serialize_users(new_friends)) # --------------------------------------------------------------------------- @api.route("/search_users/", methods=["GET"]) @@ -187,44 +187,23 @@ def discover_by_username(username): @cross_domain @requires_session def search_by_username(username): - - if not username or username.strip() == "": - return flask.abort(400, "Username cannot be empty") - - result = Friend.search_users(flask.g.user_id, username) - return json_result(result) - -# --------------------------------------------------------------------------- -@api.route("/search_friends/", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -@requires_session -def search_friends(username): - """ - Search among friends with for current user with flask.g.user_id - """ - return flask.abort(501, "Not implemented yet") + """ + Search for users with for the current user + """ + if not username or username.strip() == "": + return flask.abort(400, "Username cannot be empty") + + result = Friend.search_users(flask.g.user_id, username) + return json_result(result) # --------------------------- # Helper functions below # --------------------------- -# def _serialize_friend_request(friend_request: FriendRequest): -# result = { -# "id": friend_request.id, -# "sender_id": friend_request.sender_id, -# "receiver_id": friend_request.receiver_id, -# "created_at": friend_request.created_at, -# "reponded_at": friend_request.responded_at, -# "status": friend_request.status, -# } -# return result - - def _serialize_friend_request(fr: FriendRequest): """ Serialize a FriendRequest object into JSON-friendly dict. - + Args: fr (FriendRequest): The friend request object current_user_id (int): Optional, to simplify sender/receiver info @@ -252,39 +231,38 @@ def _serialize_friend_request(fr: FriendRequest): } def _serialize_friendship(friendship: Friend, status: str = "accepted"): - result = { - "id": friendship.id, - "sender_id": friendship.user_id, - "receiver_id": friendship.friend_id, - "created_at": friendship.created_at, - "status": status, - } - return result + return { + "id": friendship.id, + "sender_id": friendship.user_id, + "receiver_id": friendship.friend_id, + "created_at": friendship.created_at, + "status": status, + } def _serialize_user(user: User): - return { - "id": user.id, - "name": user.name, - "username": user.username, - "email": user.email, - } + return { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + } def _serialize_users(users: list[User]): - return [_serialize_user(user) for user in users] + return [_serialize_user(user) for user in users] def _is_friend_request_valid(sender_id, receiver_id)-> tuple[int, str]: - """ - :param sender_id: the user_id of the sender of the friend request - :param receiver_id: the user_id of the receiver of the friend request - Validate the friend request data, return (status_code, error_message) - - :return: (status_code, error_message) - """ - if sender_id is None or receiver_id is None: - return 422, "invalid data sender_id or/and receiver_id" - - if sender_id == receiver_id: - return 422, "cannot send friend request to yourself" - - return 200, "ok" \ No newline at end of file + """ + :param sender_id: the user_id of the sender of the friend request + :param receiver_id: the user_id of the receiver of the friend request + Validate the friend request data, return (status_code, error_message) + + :return: (status_code, error_message) + """ + if sender_id is None or receiver_id is None: + return 422, "invalid data sender_id or/and receiver_id" + + if sender_id == receiver_id: + return 422, "cannot send friend request to yourself" + + return 200, "ok" \ No newline at end of file From 33316822f9e3c937b614f2a8b8e0970167f4e1e9 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 21:05:36 +0100 Subject: [PATCH 046/142] clean up indentation in friend.py (in model folder) --- zeeguu/core/model/friend.py | 348 ++++++++++++++++++------------------ 1 file changed, 170 insertions(+), 178 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index b68903022..e787cd922 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -5,182 +5,174 @@ class Friend(db.Model): - - __tablename__ = "friends" - __table_args__ = {"mysql_collate": "utf8_bin"} - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False) - friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) - created_at = Column(DateTime, default=func.now()) - - # Explicit relationships with primaryjoin - user = relationship( - User, - foreign_keys=[user_id], - primaryjoin="Friend.user_id == User.id" - ) - friend = relationship( - User, - foreign_keys=[friend_id], - primaryjoin="Friend.friend_id == User.id" - ) - - @staticmethod - def get_friends(user_id): - """Return a list of User objects that are friends with the given user_id.""" - # query where user is either the user_id or the friend_id - friends = ( - db.session.query(User) - .join(Friend, or_(Friend.user_id == user_id, Friend.friend_id == user_id)) - .filter( - (Friend.user_id == user_id) & (User.id == Friend.friend_id) - | (Friend.friend_id == user_id) & (User.id == Friend.user_id) - ) - .all() - ) - return friends + + __tablename__ = "friends" + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) + created_at = Column(DateTime, default=func.now()) + + # Explicit relationships with primaryjoin + user = relationship( + User, + foreign_keys=[user_id], + primaryjoin="Friend.user_id == User.id" + ) + friend = relationship( + User, + foreign_keys=[friend_id], + primaryjoin="Friend.friend_id == User.id" + ) + + @staticmethod + def get_friends(user_id): + """Return a list of User objects that are friends with the given user_id.""" + # query where user is either the user_id or the friend_id + friends = ( + db.session.query(User) + .join(Friend, or_(Friend.user_id == user_id, Friend.friend_id == user_id)) + .filter( + (Friend.user_id == user_id) & (User.id == Friend.friend_id) + | (Friend.friend_id == user_id) & (User.id == Friend.user_id) + ) + .all() + ) + return friends - @classmethod - def remove_friendship(cls, user1_id: int, user2_id: int)->bool: - # Look for friendship in either direction - friendship = cls.query.filter( - ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | - ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) - ).first() - - if friendship: - db.session.delete(friendship) - db.session.commit() - return True - - return False - - - - @staticmethod - def search_users(current_user_id: int, term: str, limit: int = 20): - """ - Search users by username (partial match) or exact email. - For each user, return: - - user info - - friend request status (if any) - - friendship status (if any) - """ - from zeeguu.core.model.friend_request import FriendRequest - - # Build base query - filters = [] - if term: - filters.append(func.lower(User.username).ilike(f"%{term}%")) # ilike for case-insensitive partial match - filters.append(func.lower(User.email) == term) # exact match for email - filters.append(func.lower(User.name) == term) # exact match for name - - if not filters: - return [] # nothing to search - - query = User.query - query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) - - results = [] - for user in query.all(): - # Friendship status - friendship = Friend.query.filter( - ((Friend.user_id == current_user_id) & (Friend.friend_id == user.id)) | - ((Friend.user_id == user.id) & (Friend.friend_id == current_user_id)) - ).first() - - # Friend request status - friend_request = FriendRequest.query.filter( - ((FriendRequest.sender_id == current_user_id) & (FriendRequest.receiver_id == user.id)) | - ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) - ).order_by(FriendRequest.created_at.desc()).first() - - results.append({ - "user": { - "id": user.id, - "name": user.name, - "username": user.username, - "email": user.email, - }, - "friendship": { - "id": friendship.id if friendship else None, - "created_at": friendship.created_at.isoformat() if friendship and friendship.created_at else None, - } if friendship else None, - "friend_request": { - "id": friend_request.id if friend_request else None, - "sender_id": friend_request.sender_id if friend_request else None, - "receiver_id": friend_request.receiver_id if friend_request else None, - "status": friend_request.status if friend_request else None, - "created_at": friend_request.created_at.isoformat() if friend_request and friend_request.created_at else None, - } if friend_request else None - }) - return results - - @staticmethod - def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: - from zeeguu.core.model.friend_request import FriendRequest - - # Subquery: users already connected (either direction) - # existing_friends = db.session.query(Friend.user_id, Friend.friend_id).filter( - # or_( - # Friend.user_id == current_user_id, - # Friend.friend_id == current_user_id - # ) - # ) - - # Collect IDs to exclude - friend_ids_subquery = db.session.query( - func.if_( - Friend.user_id == current_user_id, - Friend.friend_id, - Friend.user_id - ) - ).filter( - or_( - Friend.user_id == current_user_id, - Friend.friend_id == current_user_id - ) - ) - # Query for users that already have a pending friend request with the current user - pending_request_ids = db.session.query( - FriendRequest.sender_id - ).filter( - FriendRequest.receiver_id == current_user_id, - FriendRequest.status == "pending" - ).union( - db.session.query(FriendRequest.receiver_id).filter( - FriendRequest.sender_id == current_user_id, - FriendRequest.status == "pending" - ) - ) - - query = User.query.filter( - func.lower(User.username).ilike(f"%{term}%"), # search - User.id != current_user_id, # exclude self - ~User.id.in_(friend_ids_subquery), # exclude existing friends - ~User.id.in_(pending_request_ids) # exclude users with pending friend requests - ).order_by(User.username).limit(limit) - - return query.all() - - def add_friendship(user_id: int, friend_id: int): - """ - Adds a friendship between two users using SQLAlchemy ORM. - Stores both directions for easy querying. - """ - # Check if friendship already exists - existing = Friend.query.filter( - ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | - ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) - ).first() - - if existing: - return existing # friendship already exists - - # Add friendship - friendship = Friend(user_id=user_id, friend_id=friend_id) - db.session.add(friendship) - db.session.commit() - db.session.refresh(friendship) - return friendship \ No newline at end of file + @classmethod + def remove_friendship(cls, user1_id: int, user2_id: int)->bool: + # Look for friendship in either direction + friendship = cls.query.filter( + ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | + ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) + ).first() + + if friendship: + db.session.delete(friendship) + db.session.commit() + return True + + return False + + + + @staticmethod + def search_users(current_user_id: int, term: str, limit: int = 20): + """ + Search users by username (partial match) or exact email. + For each user, return: + - user info + - friend request status (if any) + - friendship status (if any) + """ + from zeeguu.core.model.friend_request import FriendRequest + + # Build base query + filters = [] + if term: + filters.append(func.lower(User.username).ilike(f"%{term}%")) # ilike for case-insensitive partial match + filters.append(func.lower(User.email) == term) # exact match for email + filters.append(func.lower(User.name) == term) # exact match for name + + if not filters: + return [] # nothing to search + + query = User.query + query = query.filter(or_(*filters), User.id != current_user_id).limit(limit) + + results = [] + for user in query.all(): + # Friendship status + friendship = Friend.query.filter( + ((Friend.user_id == current_user_id) & (Friend.friend_id == user.id)) | + ((Friend.user_id == user.id) & (Friend.friend_id == current_user_id)) + ).first() + + # Friend request status + friend_request = FriendRequest.query.filter( + ((FriendRequest.sender_id == current_user_id) & (FriendRequest.receiver_id == user.id)) | + ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) + ).order_by(FriendRequest.created_at.desc()).first() + + results.append({ + "user": { + "id": user.id, + "name": user.name, + "username": user.username, + "email": user.email, + }, + "friendship": { + "id": friendship.id if friendship else None, + "created_at": friendship.created_at.isoformat() if friendship and friendship.created_at else None, + } if friendship else None, + "friend_request": { + "id": friend_request.id if friend_request else None, + "sender_id": friend_request.sender_id if friend_request else None, + "receiver_id": friend_request.receiver_id if friend_request else None, + "status": friend_request.status if friend_request else None, + "created_at": friend_request.created_at.isoformat() if friend_request and friend_request.created_at else None, + } if friend_request else None + }) + return results + + @staticmethod + def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: + from zeeguu.core.model.friend_request import FriendRequest + + # Collect IDs to exclude + friend_ids_subquery = db.session.query( + func.if_( + Friend.user_id == current_user_id, + Friend.friend_id, + Friend.user_id + ) + ).filter( + or_( + Friend.user_id == current_user_id, + Friend.friend_id == current_user_id + ) + ) + # Query for users that already have a pending friend request with the current user + pending_request_ids = db.session.query( + FriendRequest.sender_id + ).filter( + FriendRequest.receiver_id == current_user_id, + FriendRequest.status == "pending" + ).union( + db.session.query(FriendRequest.receiver_id).filter( + FriendRequest.sender_id == current_user_id, + FriendRequest.status == "pending" + ) + ) + + query = User.query.filter( + func.lower(User.username).ilike(f"%{term}%"), # search + User.id != current_user_id, # exclude self + ~User.id.in_(friend_ids_subquery), # exclude existing friends + ~User.id.in_(pending_request_ids) # exclude users with pending friend requests + ).order_by(User.username).limit(limit) + + return query.all() + + def add_friendship(user_id: int, friend_id: int): + """ + Adds a friendship between two users using SQLAlchemy ORM. + Stores both directions for easy querying. + """ + # Check if friendship already exists + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | + ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) + ).first() + + if existing: + return existing # friendship already exists + + # Add friendship + friendship = Friend(user_id=user_id, friend_id=friend_id) + db.session.add(friendship) + db.session.commit() + db.session.refresh(friendship) + return friendship \ No newline at end of file From 32ff0ef30e2e046c08583787eb88e7f3397fefac Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 4 Mar 2026 21:06:47 +0100 Subject: [PATCH 047/142] fixed indentation for friend_request.py --- zeeguu/core/model/friend_request.py | 324 ++++++++++++++-------------- 1 file changed, 162 insertions(+), 162 deletions(-) diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index be1e1360e..64ba07806 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -6,166 +6,166 @@ from sqlalchemy.exc import NoResultFound class FriendRequest(db.Model): - __tablename__ = "friend_requests" - __table_args__ = {"mysql_collate": "utf8_bin"} - - id = Column(Integer, primary_key=True, autoincrement=True) - sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) - receiver_id = Column(Integer, ForeignKey("user.id"), nullable=False) - status = Column( - Enum("pending", "accepted", "rejected", name="friend_request_status"), - default="pending", - ) - created_at = Column(DateTime, default=func.now()) - responded_at = Column(DateTime, nullable=True) - - # relationships - # Relationships — explicit foreign_keys and primaryjoin - sender = relationship( - User, - foreign_keys=[sender_id], - primaryjoin="FriendRequest.sender_id == User.id" - ) - receiver = relationship( - User, - foreign_keys=[receiver_id], - primaryjoin="FriendRequest.receiver_id == User.id" - ) - - def __init__( - self, - sender_id, - receiver_id, - status="pending" - ): - self.sender_id = sender_id - self.receiver_id = receiver_id - self.status = status - - - def __repr__(self): - return "id: "+ str(self.id) + "sender: "+ str(self.sender_id) + "reciever: " + str( self.receiver_id) - - @classmethod - def send_friend_request(cls, sender_id: int, receiver_id: int): - """ - Send a friend request from sender to receiver. - - Args: - sender_id (int): ID of the user sending the request - receiver_id (int): ID of the user receiving the request - - Returns: - FriendRequest: The created friend request object - """ - - # Prevent sending request to self - if sender_id == receiver_id: - raise ValueError("Cannot send a friend request to yourself.") - - # Check if users exist - sender = db.session.query(User).filter_by(id=sender_id).first() - receiver = db.session.query(User).filter_by(id=receiver_id).first() - if not sender or not receiver: - raise ValueError("Sender or receiver does not exist.") - - # Check for existing friend request - existing_request = db.session.query(cls).filter( - ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | - ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) - ).first() - - if existing_request: - raise ValueError("A friend request already exists between these users.") - - # Create new friend request - new_request = FriendRequest( - sender_id=sender_id, - receiver_id=receiver_id, - status="pending" ## TODO: This should be an enum/constant + __tablename__ = "friend_requests" + __table_args__ = {"mysql_collate": "utf8_bin"} + + id = Column(Integer, primary_key=True, autoincrement=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("user.id"), nullable=False) + status = Column( + Enum("pending", "accepted", "rejected", name="friend_request_status"), + default="pending", ) - - db.session.add(new_request) - db.session.commit() - db.session.refresh(new_request) # To get the ID and timestamps - - return new_request - - @classmethod - def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): - """ - Get friend requests received by a user. - - Args: - session (Session): SQLAlchemy session - user_id (int): ID of the user - status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" - - Returns: - List[FriendRequest]: List of friend request objects - """ - requests = ( - db.session.query(cls) - .filter(FriendRequest.receiver_id == user_id) - .filter(FriendRequest.status == status) - .order_by(FriendRequest.created_at.desc()) - .all() - ) - return requests - - @classmethod - def get_pending_friend_requests_for_user(cls, user_id: int): - """ - Get pending friend requests received by a user. - - Args: - cls (FriendRequest): The FriendRequest class - user_id (int): ID of the user - - Returns: - List[FriendRequest]: List of pending friend request objects - """ - requests = ( - db.session.query(cls) - .filter(FriendRequest.sender_id == user_id) - .filter(FriendRequest.status == "pending") - .order_by(FriendRequest.created_at.desc()) - .all() - ) - return requests - - @classmethod - def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: - """Delete a friend request between sender and receiver.""" - try: - fr = db.session.query(cls).filter_by( - sender_id=sender_id, - receiver_id=receiver_id, - status="pending" # usually only pending requests can be deleted - ).one() - db.session.delete(fr) - db.session.commit() - return True - except NoResultFound: - return False - - @classmethod - def accept_friend_request(cls, sender_id: int, receiver_id: int): - try: - # Find the pending request - cls.delete_friend_request(sender_id, receiver_id) - # Optionally create a friendship in your friends table - friendship = Friend.add_friendship(sender_id, receiver_id) - return friendship - except NoResultFound: - return None - - @classmethod - def reject_friend_request(cls, sender_id: int, receiver_id: int): - try: - - # We just delete the friend request from the database - is_deleted = cls.delete_friend_request(sender_id, receiver_id) - return is_deleted - except NoResultFound: - return None \ No newline at end of file + created_at = Column(DateTime, default=func.now()) + responded_at = Column(DateTime, nullable=True) + + # relationships + # Relationships — explicit foreign_keys and primaryjoin + sender = relationship( + User, + foreign_keys=[sender_id], + primaryjoin="FriendRequest.sender_id == User.id" + ) + receiver = relationship( + User, + foreign_keys=[receiver_id], + primaryjoin="FriendRequest.receiver_id == User.id" + ) + + def __init__( + self, + sender_id, + receiver_id, + status="pending" + ): + self.sender_id = sender_id + self.receiver_id = receiver_id + self.status = status + + + def __repr__(self): + return "id: "+ str(self.id) + "sender: "+ str(self.sender_id) + "reciever: " + str( self.receiver_id) + + @classmethod + def send_friend_request(cls, sender_id: int, receiver_id: int): + """ + Send a friend request from sender to receiver. + + Args: + sender_id (int): ID of the user sending the request + receiver_id (int): ID of the user receiving the request + + Returns: + FriendRequest: The created friend request object + """ + + # Prevent sending request to self + if sender_id == receiver_id: + raise ValueError("Cannot send a friend request to yourself.") + + # Check if users exist + sender = db.session.query(User).filter_by(id=sender_id).first() + receiver = db.session.query(User).filter_by(id=receiver_id).first() + if not sender or not receiver: + raise ValueError("Sender or receiver does not exist.") + + # Check for existing friend request + existing_request = db.session.query(cls).filter( + ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | + ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) + ).first() + + if existing_request: + raise ValueError("A friend request already exists between these users.") + + # Create new friend request + new_request = FriendRequest( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" ## TODO: This should be an enum/constant + ) + + db.session.add(new_request) + db.session.commit() + db.session.refresh(new_request) # To get the ID and timestamps + + return new_request + + @classmethod + def get_friend_requests_for_user(cls, user_id: int, status: str = "pending"): + """ + Get friend requests received by a user. + + Args: + session (Session): SQLAlchemy session + user_id (int): ID of the user + status (str): Filter by status ("pending", "accepted", "rejected"). Default: "pending" + + Returns: + List[FriendRequest]: List of friend request objects + """ + requests = ( + db.session.query(cls) + .filter(FriendRequest.receiver_id == user_id) + .filter(FriendRequest.status == status) + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests + + @classmethod + def get_pending_friend_requests_for_user(cls, user_id: int): + """ + Get pending friend requests received by a user. + + Args: + cls (FriendRequest): The FriendRequest class + user_id (int): ID of the user + + Returns: + List[FriendRequest]: List of pending friend request objects + """ + requests = ( + db.session.query(cls) + .filter(FriendRequest.sender_id == user_id) + .filter(FriendRequest.status == "pending") + .order_by(FriendRequest.created_at.desc()) + .all() + ) + return requests + + @classmethod + def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: + """Delete a friend request between sender and receiver.""" + try: + fr = db.session.query(cls).filter_by( + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" # usually only pending requests can be deleted + ).one() + db.session.delete(fr) + db.session.commit() + return True + except NoResultFound: + return False + + @classmethod + def accept_friend_request(cls, sender_id: int, receiver_id: int): + try: + # Find the pending request + cls.delete_friend_request(sender_id, receiver_id) + # Optionally create a friendship in your friends table + friendship = Friend.add_friendship(sender_id, receiver_id) + return friendship + except NoResultFound: + return None + + @classmethod + def reject_friend_request(cls, sender_id: int, receiver_id: int): + try: + + # We just delete the friend request from the database + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + return is_deleted + except NoResultFound: + return None \ No newline at end of file From 87ced7c249141447593012d4c532d690a0993382 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 5 Mar 2026 11:01:35 +0100 Subject: [PATCH 048/142] Streak count badge now calculates the highest current value among all learned languages --- zeeguu/api/endpoints/user_languages.py | 2 +- zeeguu/api/utils/route_wrappers.py | 29 +++++++++++++++++++------- zeeguu/core/model/user_language.py | 14 +++++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/zeeguu/api/endpoints/user_languages.py b/zeeguu/api/endpoints/user_languages.py index 84ad12c57..92660c059 100644 --- a/zeeguu/api/endpoints/user_languages.py +++ b/zeeguu/api/endpoints/user_languages.py @@ -105,7 +105,7 @@ def get_user_languages(): """ all_user_languages = [] user = User.find_by_id(flask.g.user_id) - user_languages = UserLanguage.all_for_user(user) + user_languages = UserLanguage.all_languages_for_user(user) for lan in user_languages: all_user_languages.append(lan.as_dictionary()) return json_result(all_user_languages) diff --git a/zeeguu/api/utils/route_wrappers.py b/zeeguu/api/utils/route_wrappers.py index 115889fad..c4543b6f7 100644 --- a/zeeguu/api/utils/route_wrappers.py +++ b/zeeguu/api/utils/route_wrappers.py @@ -89,14 +89,27 @@ def wrapped_view(*args, **kwargs): if user: user.update_last_seen_if_needed(db.session) - # Reset streak if user hasn't practiced in 2+ days - # (streak is only incremented in actual practice endpoints) + # Reset streak for all learned languages if user hasn't practiced in 2+ days + # (streak is only incremented in actual practice endpoints). if user.learned_language: - user_language = UserLanguage.find_or_create( - db.session, user, user.learned_language - ) - user_language.reset_streak_if_broken(db.session) - # Commit immediately since this is a simple timestamp update + user_languages = UserLanguage.all_user_languages_for_user(user) + daily_streak_badge_progress = 0 + any_streak_changed = False + for user_language in user_languages: + original_streak = user_language.daily_streak + user_language.reset_streak_if_broken(db.session) + updated_streak = user_language.daily_streak + if updated_streak > daily_streak_badge_progress: + daily_streak_badge_progress = updated_streak + if original_streak != updated_streak: + any_streak_changed = True + + # If any learned language's daily streak has been reset, we need to update the STREAK_COUNT badge's + # progress to the new highest streak among all languages. + if any_streak_changed: + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress + update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) + db.session.commit() # Check email verification (unless endpoint is marked as allowing unverified) @@ -211,7 +224,7 @@ def update_user_streak(): user_language = UserLanguage.find_or_create( db.session, user, user.learned_language ) - user_language.update_streak_if_needed(db.session) + user_language.update_streak_if_needed(user, db.session) db.session.commit() diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index bd96739b3..ac369f248 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -110,7 +110,7 @@ def with_language_id(cls, i, user): return cls.query.filter(cls.user == user).filter(cls.language_id == i).one() @classmethod - def all_for_user(cls, user): + def all_languages_for_user(cls, user): user_main_learned_language = user.learned_language user_languages = [ language_id.language @@ -122,7 +122,11 @@ def all_for_user(cls, user): return user_languages - def update_streak_if_needed(self, session=None): + @classmethod + def all_user_languages_for_user(cls, user): + return cls.query.filter(cls.user == user).all() + + def update_streak_if_needed(self, user, session=None): """ Update last_practiced timestamp and daily_streak counter for this language. Call this when user performs actual practice (exercises, reading, etc.). @@ -145,6 +149,12 @@ def update_streak_if_needed(self, session=None): if session: session.add(self) + from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress + daily_streak_badge_progress = max( + [user_language.daily_streak for user_language in self.all_user_languages_for_user(user)] + ) + update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) + def reset_streak_if_broken(self, session=None): """ Reset streak to 0 if user hasn't practiced since yesterday. From f6db4c21aa65bbecf39761b9c1c878cc86326261 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 7 Mar 2026 12:19:03 +0100 Subject: [PATCH 049/142] Removed badge is_hidden field, updated badge default values --- tools/migrations/26-02-19--add_badges.sql | 5 +- .../26-02-28--insert_default_badges.sql | 81 ++++++++++--------- zeeguu/api/endpoints/badges.py | 4 +- zeeguu/core/model/badge.py | 2 +- zeeguu/core/model/badge_level.py | 2 +- 5 files changed, 48 insertions(+), 46 deletions(-) diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index 64867049b..66e9a8ff5 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -4,8 +4,7 @@ CREATE TABLE badge ( id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL, - description TEXT, -- We could store a template string, and would interpolate the target values. - is_hidden BOOLEAN DEFAULT FALSE, + description TEXT, UNIQUE(code) ); @@ -15,7 +14,7 @@ CREATE TABLE badge_level ( name VARCHAR(100), level INT NOT NULL, target_value INT NOT NULL, - icon_url VARCHAR(255), + icon_name VARCHAR(255), UNIQUE(badge_id, level), FOREIGN KEY (badge_id) REFERENCES badge(id) ); diff --git a/tools/migrations/26-02-28--insert_default_badges.sql b/tools/migrations/26-02-28--insert_default_badges.sql index b0e3077d2..5b2b9d1eb 100644 --- a/tools/migrations/26-02-28--insert_default_badges.sql +++ b/tools/migrations/26-02-28--insert_default_badges.sql @@ -2,58 +2,61 @@ INSERT INTO badge (id, code, name, description, is_hidden) VALUES - (1, 'TRANSLATED_WORDS', 'Meaning Builder', 'Translate {target_value} words while reading.', FALSE), - (2, 'CORRECT_EXERCISES', 'Practice Builder', 'Solve {target_value} exercises correctly.', FALSE), - (3, 'COMPLETED_AUDIO_LESSONS', 'Sound Scholar', 'Complete {target_value} audio lessons.', FALSE), - (4, 'STREAK_COUNT', 'Consistency Champion', 'Maintain your streak for {target_value} days.', FALSE), - (5, 'LEARNED_WORDS', 'Word Collector', 'Learn {target_value} new words.', FALSE), - (6, 'READ_ARTICLES', 'Active Reader', 'Read {target_value} articles.', FALSE); - -INSERT INTO badge_level (id, badge_id, name, level, target_value, icon_url) + (1, 'TRANSLATED_WORDS', 'Meaning Builder', 'Translate {target_value} unique words while reading.'), + (2, 'CORRECT_EXERCISES', 'Practice Builder', 'Solve {target_value} exercises correctly.'), + (3, 'COMPLETED_AUDIO_LESSONS', 'Sound Scholar', 'Complete {target_value} audio lessons.'), + (4, 'STREAK_COUNT', 'Consistency Champion', 'Maintain a streak for {target_value} days.'), + (5, 'LEARNED_WORDS', 'Word Collector', 'Learn {target_value} new words.'), + (6, 'READ_ARTICLES', 'Active Reader', 'Read {target_value} articles.'), + (7, 'NUMBER_OF_FRIENDS', 'Influencer', 'Add {target_value} friends.'); + +INSERT INTO badge_level (id, badge_id, name, level, target_value, icon_name) VALUES -- Translated Words - (1, 1, '', 1, 50, NULL), + (1, 1, '', 1, 10, NULL), (2, 1, '', 2, 100, NULL), (3, 1, '', 3, 500, NULL), (4, 1, '', 4, 1000, NULL), (5, 1, '', 5, 2500, NULL), - (6, 1, '', 6, 5000, NULL), -- Correct Exercises - (7, 2, '', 1, 10, NULL), - (8, 2, '', 2, 50, NULL), - (9, 2, '', 3, 200, NULL), - (10, 2, '', 4, 500, NULL), - (11, 2, '', 5, 1000, NULL), - (12, 2, '', 6, 5000, NULL), + (6, 2, '', 1, 10, NULL), + (7, 2, '', 2, 250, NULL), + (8, 2, '', 3, 1000, NULL), + (9, 2, '', 4, 5000, NULL), + (10, 2, '', 5, 20000, NULL), -- Completed Audio Lessons - (13, 3, '', 1, 1, NULL), - (14, 3, '', 2, 10, NULL), - (15, 3, '', 3, 50, NULL), - (16, 3, '', 4, 100, NULL), - (17, 3, '', 5, 250, NULL), - (18, 3, '', 6, 500, NULL), + (11, 3, '', 1, 1, NULL), + (12, 3, '', 2, 25, NULL), + (13, 3, '', 3, 50, NULL), + (14, 3, '', 4, 150, NULL), + (15, 3, '', 5, 300, NULL), -- Streak Count - (19, 4, '', 1, 7, NULL), - (20, 4, '', 2, 21, NULL), - (21, 4, '', 3, 60, NULL), - (22, 4, '', 4, 180, NULL), - (23, 4, '', 5, 365, NULL), + (16, 4, '', 1, 7, NULL), + (17, 4, '', 2, 30, NULL), + (18, 4, '', 3, 90, NULL), + (19, 4, '', 4, 180, NULL), + (20, 4, '', 5, 365, NULL), -- Learned Words - (24, 5, '', 1, 10, NULL), - (25, 5, '', 2, 50, NULL), - (26, 5, '', 3, 100, NULL), - (27, 5, '', 4, 250, NULL), - (28, 5, '', 5, 500, NULL), - (29, 5, '', 6, 1000, NULL), + (21, 5, '', 1, 1, NULL), + (22, 5, '', 2, 10, NULL), + (23, 5, '', 3, 50, NULL), + (24, 5, '', 4, 250, NULL), + (25, 5, '', 5, 750, NULL), -- Read Articles - (30, 6, '', 1, 5, NULL), - (31, 6, '', 2, 20, NULL), - (32, 6, '', 3, 50, NULL), - (33, 6, '', 4, 100, NULL), - (34, 6, '', 5, 250, NULL), - (35, 6, '', 6, 500, NULL); + (26, 6, '', 1, 5, NULL), + (27, 6, '', 2, 25, NULL), + (28, 6, '', 3, 100, NULL), + (29, 6, '', 4, 500, NULL), + (30, 6, '', 5, 1000, NULL), + + -- Number of Friends + (31, 7, '', 1, 1, NULL), + (32, 7, '', 2, 3, NULL), + (33, 7, '', 3, 5, NULL), + (34, 7, '', 4, 7, NULL), + (35, 7, '', 5, 10, NULL); diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 0f5e169ac..8f430a0a5 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -43,7 +43,7 @@ def get_badges_for_user(): { "badge_level": 1, "target_value": 50, - "icon_url": "/icons/badge1.png", + "icon_name": "/badge1.svg", "achieved": true, "achieved_at": "2026-03-03T12:34:56", "is_shown": false, @@ -108,7 +108,7 @@ def serialize_badge_level(level: BadgeLevel, user_level: UserBadgeLevel | None) return { "badge_level": level.level, "target_value": level.target_value, - "icon_url": level.icon_url, + "icon_name": level.icon_name, "achieved": user_level is not None, "achieved_at": ( user_level.achieved_at.isoformat() diff --git a/zeeguu/core/model/badge.py b/zeeguu/core/model/badge.py index 448d8934e..59278cee4 100644 --- a/zeeguu/core/model/badge.py +++ b/zeeguu/core/model/badge.py @@ -12,6 +12,7 @@ class BadgeCode(enum.Enum): STREAK_COUNT = 'STREAK_COUNT' LEARNED_WORDS = 'LEARNED_WORDS' READ_ARTICLES = 'READ_ARTICLES' + NUMBER_OF_FRIENDS = 'NUMBER_OF_FRIENDS' class Badge(db.Model): @@ -25,7 +26,6 @@ class Badge(db.Model): code = db.Column(db.Enum(BadgeCode), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) - is_hidden = db.Column(db.Boolean, nullable=False, default=False) __table_args__ = ( db.UniqueConstraint("code"), diff --git a/zeeguu/core/model/badge_level.py b/zeeguu/core/model/badge_level.py index 69ebab0f6..7e1b516d5 100644 --- a/zeeguu/core/model/badge_level.py +++ b/zeeguu/core/model/badge_level.py @@ -13,7 +13,7 @@ class BadgeLevel(db.Model): badge_id = db.Column(db.Integer, db.ForeignKey("badge.id"), nullable=False) level = db.Column(db.Integer, nullable=False) target_value = db.Column(db.Integer, nullable=False) - icon_url = db.Column(db.String(255)) + icon_name = db.Column(db.String(255)) __table_args__ = ( db.UniqueConstraint("badge_id", "level"), From b3ad1dad75615146b3b783879e2407b304eb0943 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 17:47:18 +0100 Subject: [PATCH 050/142] Add hide_recommendations feature flag for cohort 564 Students in cohort 564 will only see the Classroom tab with teacher-uploaded texts, not the Recommended articles page. Co-Authored-By: Claude Opus 4.5 --- zeeguu/core/user_feature_toggles.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 4ba6152ad..0dba20658 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -15,6 +15,7 @@ def _feature_map(): "tiago_exercises": _tiago_exercises, "new_topics": _new_topics, "daily_feedback": _daily_feedback, + "hide_recommendations": _hide_recommendations, } @@ -65,3 +66,16 @@ def _extension_experiment_1(user): or user.id in [3372, 3373, 2953, 3427, 2705] or user.id > 3555 ) + + +def _hide_recommendations(user): + """Hide recommended articles for students in specific cohorts. + + When enabled, students only see the Classroom tab with teacher-uploaded texts. + """ + 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 From ddda23d8d5639941d8b8209aaa169be2d6e67979 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Thu, 26 Feb 2026 17:50:27 +0100 Subject: [PATCH 051/142] Exclude teachers from hide_recommendations feature Teachers should always see recommendations even if they are members of cohort 564. Co-Authored-By: Claude Opus 4.5 --- zeeguu/core/user_feature_toggles.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 0dba20658..13762a596 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -72,7 +72,11 @@ def _hide_recommendations(user): """Hide recommended articles for students in specific cohorts. When enabled, students only see the Classroom tab with teacher-uploaded texts. + Teachers are excluded from this feature even if they are in the cohort. """ + if user.isTeacher(): + return False + COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} for user_cohort in user.cohorts: From 04f37c77a87d334d5af21d5905231965c023f8a1 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Thu, 5 Mar 2026 20:25:41 +0200 Subject: [PATCH 052/142] Update .envrc to use local .venv --- .envrc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.envrc b/.envrc index a4ab26525..08f143022 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1 @@ -source ~/.venvs/z_env/bin/activate - +source $GH_FOLDER/zeeguu/api/.venv/bin/activate From 1231aa6a9cf1624ec46da35755fe1b337a73aec9 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Thu, 5 Mar 2026 20:26:21 +0200 Subject: [PATCH 053/142] Update CLAUDE.md venv path --- CLAUDE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index abb8c037a..6ecb764e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,19 @@ # Project Configuration for Claude ## Python Environment -- **Always use the virtual environment**: `~/.venvs/z_env` -- **Python commands should be prefixed with**: `source ~/.venvs/z_env/bin/activate && ` +- **Always use the virtual environment**: `$GH_FOLDER/zeeguu/api/.venv` +- **Python commands should be prefixed with**: `source $GH_FOLDER/zeeguu/api/.venv/bin/activate && ` ## Examples: ```bash # Running Python scripts -source ~/.venvs/z_env/bin/activate && python -m tools._playground +source $GH_FOLDER/zeeguu/api/.venv/bin/activate && python -m tools._playground # Running tests -source ~/.venvs/z_env/bin/activate && python -m pytest +source $GH_FOLDER/zeeguu/api/.venv/bin/activate && python -m pytest # Any Python-related command -source ~/.venvs/z_env/bin/activate && python +source $GH_FOLDER/zeeguu/api/.venv/bin/activate && python ``` ## Tool Scripts Structure From 649bdc9f22413f9e1583ae1d40a8cc90eda305fe Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Mon, 9 Mar 2026 21:37:01 +0100 Subject: [PATCH 054/142] Working on test for the friends endpoints --- zeeguu/api/test/test_friends.py | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 zeeguu/api/test/test_friends.py diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py new file mode 100644 index 000000000..3db3ec9cf --- /dev/null +++ b/zeeguu/api/test/test_friends.py @@ -0,0 +1,145 @@ +from fixtures import LoggedInClient, logged_in_client as client +from fixtures import add_context_types, add_source_types, create_and_get_article + +def test_accept_friend_request_success(client: LoggedInClient): + """ + Test accepting a friend request returns friendship dict. + """ + from zeeguu.core.model import User + # Create another user and send friend request + user_data = dict(password="test", username="accept", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + sender_user = User.find(other_email) + + other_email = "accept@user.com" + other_user = User.find(other_email) + print(other_user) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + # Accept as other user + # Simulate login as other user + + other_client = LoggedInClient(client.client) + response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + assert isinstance(response, dict) + assert "status" in response + + +def test_reject_friend_request_success(client: LoggedInClient): + """ + Test rejecting a friend request returns True or similar. + """ + other_email = "reject@user.com" + user_data = dict(password="test", username="reject", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + from zeeguu.api.test.fixtures import LoggedInClient + other_client = LoggedInClient(client.client) + response = other_client.post("/reject_friend_request", json={"sender_id": client.email}) + assert response is True or str(response) == "True" or "True" in str(response) + +def test_delete_friend_request_success(client: LoggedInClient): + """ + Test deleting a friend request returns 'True' or similar. + """ + # Create another user and send friend request + other_email = "delete@user.com" + user_data = dict(password="test", username="delete", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + response = client.post("/delete_friend_request", json={"receiver_id": other_user.id}) + assert "True" in str(response) or response is True + + +def test_delete_friend_request_invalid_receiver(client: LoggedInClient): + """ + Test deleting a friend request with invalid receiver returns error. + """ + response = client.post("/delete_friend_request", json={"receiver_id": 999999}) + assert "invalid data sender_id or/and receiver_id" in str(response) + +def test_send_friend_request_success(client: LoggedInClient): + """ + Test sending a friend request to another user returns expected dict. + """ + # Create another user + other_email = "other@user.com" + user_data = dict(password="test", username="other", learned_language="de") + client.client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + # Send friend request + response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert isinstance(response, dict) + assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id + + +def test_send_friend_request_to_self(client): + """ + Test sending a friend request to self returns error. + """ + from zeeguu.core.model import User + user = User.find(client.email) + response = client.post("/send_friend_request", json={"receiver_id": user.id}) + assert b"cannot send friend request to yourself" in response or "cannot send friend request to yourself" in str(response) +def test_get_friend_requests(client): + """ + Test the /get_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_friend_requests") + assert isinstance(response, list) + + +def test_get_pending_friend_requests(client): + """ + Test the /get_pending_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_pending_friend_requests") + assert isinstance(response, list) + +def test_unfriend_success(client: LoggedInClient): + """ + Test unfriending removes friendship. + """ + other_email = "unfriend@user.com" + user_data = dict(password="test", username="unfriend", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + from zeeguu.api.test.fixtures import LoggedInClient + other_client = LoggedInClient(client.client) + other_client.post("/accept_friend_request", json={"sender_id": client.email}) + response = client.post("/unfriend", json={"receiver_id": other_user.id}) + assert response is True or str(response) == "True" or "True" in str(response) + + +def test_discover_friends(client): + """ + Test discover_friends returns a list. + """ + response = client.get("/discover_friends/test") + assert isinstance(response, list) + + +def test_search_users(client): + """ + Test search_users returns a list. + """ + response = client.get("/search_users/test") + assert isinstance(response, list) +import pytest +from fixtures import logged_in_client as client +from fixtures import add_context_types, add_source_types, create_and_get_article + + +def test_get_friends(client): + """ + Test the /get_friends endpoint returns a list (empty or not). + """ + response = client.get("/get_friends") + assert isinstance(response, list) + From 836d0d41fef5d5be41e7006aed055fc0a5f7068b Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 10:54:58 +0100 Subject: [PATCH 055/142] Tests for the friends endpoints --- zeeguu/api/endpoints/friends.py | 31 ++++---- zeeguu/api/test/fixtures.py | 18 ++++- zeeguu/api/test/test_friends.py | 119 +++++++++++++++------------- zeeguu/core/model/friend_request.py | 4 +- 4 files changed, 99 insertions(+), 73 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index ffa55322f..8a57fe6d6 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -5,6 +5,7 @@ from zeeguu.core.model.friend_request import FriendRequest from zeeguu.api.utils.json_result import json_result from sqlalchemy.orm.exc import NoResultFound +from zeeguu.api.utils.abort_handling import make_error from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api @@ -68,16 +69,16 @@ def send_friend_request(): status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - flask.abort(status_code, error_message) + return make_error(status_code, error_message) try: friend_request = FriendRequest.send_friend_request(sender_id, receiver_id) response = _serialize_friend_request(friend_request) return json_result(response) except ValueError as e: - return flask.abort(400, str(e)) + return make_error(400, str(e)) except NoResultFound: - return flask.abort(404, "User not found") + return make_error(404, "User not found") # --------------------------------------------------------------------------- @@ -94,10 +95,10 @@ def delete_friend_request(): status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - return flask.abort(status_code, error) + return make_error(status_code, error) is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) - return json_result(str(is_deleted)) + return json_result({"success": is_deleted}) # --------------------------------------------------------------------------- @api.route("/accept_friend_request", methods=["POST"]) @@ -114,11 +115,11 @@ def accept_friend_request(): print(f"sender_id: {sender_id}") status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - return flask.abort(status_code, error) + return make_error(status_code, error) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: - return flask.abort(404, "No friend request found to accept") + return make_error(404, "No friend request found to accept") response = _serialize_friendship(friendship) return json_result(response) @@ -138,10 +139,10 @@ def reject_friend_request(): status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - return flask.abort(status_code, error_message) + return make_error(status_code, error_message) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) - return json_result(is_rejected) + return json_result({"success": is_rejected}) # --------------------------------------------------------------------------- @api.route("/unfriend", methods=["POST"]) @@ -157,10 +158,10 @@ def unfriend(): status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: - return flask.abort(status_code, error_message) + return make_error(status_code, error_message) is_removed = Friend.remove_friendship(sender_id, receiver_id) - return json_result(str(is_removed)) + return json_result({"success": is_removed}) # --------------------------------------------------------------------------- @@ -191,7 +192,7 @@ def search_by_username(username): Search for users with for the current user """ if not username or username.strip() == "": - return flask.abort(400, "Username cannot be empty") + return make_error(400, "Username cannot be empty") result = Friend.search_users(flask.g.user_id, username) return json_result(result) @@ -225,7 +226,7 @@ def _serialize_friend_request(fr: FriendRequest): "username": fr.receiver.username, # This will be updated to username "email": fr.receiver.email, # Is this relevant? }, - "status": fr.status, + "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, } @@ -236,8 +237,8 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): "sender_id": friendship.user_id, "receiver_id": friendship.friend_id, "created_at": friendship.created_at, - "status": status, - } + "friend_request_status": status, + } def _serialize_user(user: User): return { diff --git a/zeeguu/api/test/fixtures.py b/zeeguu/api/test/fixtures.py index e7999fe7f..c5a49565c 100644 --- a/zeeguu/api/test/fixtures.py +++ b/zeeguu/api/test/fixtures.py @@ -41,12 +41,24 @@ def logged_in_teacher(app, _mock_web): class LoggedInClient: - def __init__(self, client): + + def __init__( + self, + client, + email="i@mir.lu", + password="test", + username="test", + learned_language="de"): + self.client = client # Creating a user and returning also the session - test_user_data = dict(password="test", username="test", learned_language="de") - self.email = "i@mir.lu" + test_user_data = dict( + username=username, + password=password, + learned_language=learned_language) + + self.email = email response = self.client.post(f"/add_user/{self.email}", data=test_user_data) assert response.status_code == 200 diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 3db3ec9cf..f3c974eda 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -1,43 +1,55 @@ from fixtures import LoggedInClient, logged_in_client as client from fixtures import add_context_types, add_source_types, create_and_get_article +from zeeguu.core.model import User +import json def test_accept_friend_request_success(client: LoggedInClient): """ Test accepting a friend request returns friendship dict. """ - from zeeguu.core.model import User # Create another user and send friend request - user_data = dict(password="test", username="accept", learned_language="de") - client.post(f"/add_user/{other_email}", data=user_data) - sender_user = User.find(other_email) - other_email = "accept@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="accept", + learned_language="de") + + # Get users + sender_user = User.find(client.email) other_user = User.find(other_email) - print(other_user) - client.post("/send_friend_request", json={"receiver_id": other_user.id}) - # Accept as other user - # Simulate login as other user - other_client = LoggedInClient(client.client) - response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) - assert isinstance(response, dict) - assert "status" in response + # Send friend request + fr_response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert fr_response["friend_request_status"] == "pending" + + # User other client to accept friend request + accept_fr_response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + print(accept_fr_response) + assert accept_fr_response["friend_request_status"] == "accepted" def test_reject_friend_request_success(client: LoggedInClient): - """ - Test rejecting a friend request returns True or similar. - """ - other_email = "reject@user.com" - user_data = dict(password="test", username="reject", learned_language="de") - client.post(f"/add_user/{other_email}", data=user_data) - from zeeguu.core.model import User - other_user = User.find(other_email) - client.post("/send_friend_request", json={"receiver_id": other_user.id}) - from zeeguu.api.test.fixtures import LoggedInClient - other_client = LoggedInClient(client.client) - response = other_client.post("/reject_friend_request", json={"sender_id": client.email}) - assert response is True or str(response) == "True" or "True" in str(response) + """ + Test rejecting a friend request returns success message. + """ + other_email = "reject@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="reject", + learned_language="de") + + # Find users and ids + other_user = User.find(other_email) + sender_user = User.find(client.email) + + # Act: Send friend request and reject it + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + response = other_client.post("/reject_friend_request", json={"sender_id": sender_user.id}) + + # Assert + assert response.get("success") is True def test_delete_friend_request_success(client: LoggedInClient): """ @@ -47,7 +59,7 @@ def test_delete_friend_request_success(client: LoggedInClient): other_email = "delete@user.com" user_data = dict(password="test", username="delete", learned_language="de") client.post(f"/add_user/{other_email}", data=user_data) - from zeeguu.core.model import User + 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}) @@ -59,7 +71,7 @@ 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 "invalid data sender_id or/and receiver_id" in str(response) + assert response.get("success") is False def test_send_friend_request_success(client: LoggedInClient): """ @@ -68,7 +80,7 @@ def test_send_friend_request_success(client: LoggedInClient): # Create another user other_email = "other@user.com" user_data = dict(password="test", username="other", learned_language="de") - client.client.post(f"/add_user/{other_email}", data=user_data) + client.post(f"/add_user/{other_email}", data=user_data) from zeeguu.core.model import User other_user = User.find(other_email) # Send friend request @@ -77,7 +89,7 @@ def test_send_friend_request_success(client: LoggedInClient): assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id -def test_send_friend_request_to_self(client): +def test_send_friend_request_to_self(client: LoggedInClient): """ Test sending a friend request to self returns error. """ @@ -85,7 +97,8 @@ def test_send_friend_request_to_self(client): user = User.find(client.email) response = client.post("/send_friend_request", json={"receiver_id": user.id}) assert b"cannot send friend request to yourself" in response or "cannot send friend request to yourself" in str(response) -def test_get_friend_requests(client): + +def test_get_friend_requests(client: LoggedInClient): """ Test the /get_friend_requests endpoint returns a list (empty or not). """ @@ -93,7 +106,7 @@ def test_get_friend_requests(client): assert isinstance(response, list) -def test_get_pending_friend_requests(client): +def test_get_pending_friend_requests(client: LoggedInClient): """ Test the /get_pending_friend_requests endpoint returns a list (empty or not). """ @@ -101,23 +114,27 @@ def test_get_pending_friend_requests(client): assert isinstance(response, list) def test_unfriend_success(client: LoggedInClient): - """ - Test unfriending removes friendship. - """ - other_email = "unfriend@user.com" - user_data = dict(password="test", username="unfriend", learned_language="de") - client.post(f"/add_user/{other_email}", data=user_data) - from zeeguu.core.model import User - other_user = User.find(other_email) - client.post("/send_friend_request", json={"receiver_id": other_user.id}) - from zeeguu.api.test.fixtures import LoggedInClient - other_client = LoggedInClient(client.client) - other_client.post("/accept_friend_request", json={"sender_id": client.email}) - response = client.post("/unfriend", json={"receiver_id": other_user.id}) - assert response is True or str(response) == "True" or "True" in str(response) + other_email = "unfriend@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="unfriend", + learned_language="de") + sender_user = User.find(client.email) + other_user = User.find(other_email) + + # Send and accept friend request + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + # Act: Unfirend + response = client.post("/unfriend", json={"receiver_id": other_user.id}) + + # Assert + assert response.get("success") is True -def test_discover_friends(client): +def test_discover_friends(client: LoggedInClient): """ Test discover_friends returns a list. """ @@ -125,18 +142,14 @@ def test_discover_friends(client): assert isinstance(response, list) -def test_search_users(client): +def test_search_users(client: LoggedInClient): """ Test search_users returns a list. """ response = client.get("/search_users/test") assert isinstance(response, list) -import pytest -from fixtures import logged_in_client as client -from fixtures import add_context_types, add_source_types, create_and_get_article - -def test_get_friends(client): +def test_get_friends(client: LoggedInClient): """ Test the /get_friends endpoint returns a list (empty or not). """ diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 64ba07806..03f94753e 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -158,7 +158,7 @@ def accept_friend_request(cls, sender_id: int, receiver_id: int): friendship = Friend.add_friendship(sender_id, receiver_id) return friendship except NoResultFound: - return None + return False @classmethod def reject_friend_request(cls, sender_id: int, receiver_id: int): @@ -168,4 +168,4 @@ def reject_friend_request(cls, sender_id: int, receiver_id: int): is_deleted = cls.delete_friend_request(sender_id, receiver_id) return is_deleted except NoResultFound: - return None \ No newline at end of file + return False \ No newline at end of file From 3d09894164760585e8a7b9ebb5c2b7c31e576588 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 12:00:45 +0100 Subject: [PATCH 056/142] Removed discover_friends endpoint --- zeeguu/api/endpoints/friends.py | 18 +++------------- zeeguu/api/test/test_friends.py | 10 --------- zeeguu/core/model/friend.py | 38 --------------------------------- 3 files changed, 3 insertions(+), 63 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 8a57fe6d6..84c2af855 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -169,19 +169,6 @@ def unfriend(): # --------------------------------------------------------------------------- -# --------------------------------------------------------------------- ------ -@api.route("/discover_friends/", methods=["GET"]) -# --------------------------------------------------------------------------- -@cross_domain -@requires_session -def discover_by_username(username): - """ - Search for new friends with of a user by user_id - """ - user_id = flask.g.user_id - new_friends = Friend.search_for_new_friends(user_id, username) - return json_result(_serialize_users(new_friends)) - # --------------------------------------------------------------------------- @api.route("/search_users/", methods=["GET"]) # --------------------------------------------------------------------------- @@ -197,9 +184,10 @@ def search_by_username(username): result = Friend.search_users(flask.g.user_id, username) return json_result(result) -# --------------------------- + +# --------------------------------------------------------------------------- # Helper functions below -# --------------------------- +# --------------------------------------------------------------------------- def _serialize_friend_request(fr: FriendRequest): """ diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index f3c974eda..495b724c6 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -25,7 +25,6 @@ def test_accept_friend_request_success(client: LoggedInClient): # User other client to accept friend request accept_fr_response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) - print(accept_fr_response) assert accept_fr_response["friend_request_status"] == "accepted" @@ -133,15 +132,6 @@ def test_unfriend_success(client: LoggedInClient): # Assert assert response.get("success") is True - -def test_discover_friends(client: LoggedInClient): - """ - Test discover_friends returns a list. - """ - response = client.get("/discover_friends/test") - assert isinstance(response, list) - - def test_search_users(client: LoggedInClient): """ Test search_users returns a list. diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index e787cd922..75d4291b2 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -117,44 +117,6 @@ def search_users(current_user_id: int, term: str, limit: int = 20): }) return results - @staticmethod - def search_for_new_friends(current_user_id: int, term: str, limit: int = 20) -> list[User]: - from zeeguu.core.model.friend_request import FriendRequest - - # Collect IDs to exclude - friend_ids_subquery = db.session.query( - func.if_( - Friend.user_id == current_user_id, - Friend.friend_id, - Friend.user_id - ) - ).filter( - or_( - Friend.user_id == current_user_id, - Friend.friend_id == current_user_id - ) - ) - # Query for users that already have a pending friend request with the current user - pending_request_ids = db.session.query( - FriendRequest.sender_id - ).filter( - FriendRequest.receiver_id == current_user_id, - FriendRequest.status == "pending" - ).union( - db.session.query(FriendRequest.receiver_id).filter( - FriendRequest.sender_id == current_user_id, - FriendRequest.status == "pending" - ) - ) - - query = User.query.filter( - func.lower(User.username).ilike(f"%{term}%"), # search - User.id != current_user_id, # exclude self - ~User.id.in_(friend_ids_subquery), # exclude existing friends - ~User.id.in_(pending_request_ids) # exclude users with pending friend requests - ).order_by(User.username).limit(limit) - - return query.all() def add_friendship(user_id: int, friend_id: int): """ From 510454d96ae54d518a11827362b4591de40ed4c8 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:10:50 +0100 Subject: [PATCH 057/142] Fixed spaces in tests --- zeeguu/api/test/test_friends.py | 164 ++++++++++++++++---------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 495b724c6..64d5b8047 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -4,34 +4,34 @@ import json def test_accept_friend_request_success(client: LoggedInClient): - """ - Test accepting a friend request returns friendship dict. - """ - # Create another user and send friend request - other_email = "accept@user.com" - other_client = LoggedInClient(client.client, - email=other_email, - password="test", - username="accept", - learned_language="de") - - # Get users - sender_user = User.find(client.email) - other_user = User.find(other_email) - - # Send friend request - fr_response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) - assert fr_response["friend_request_status"] == "pending" - - # User other client to accept friend request - accept_fr_response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) - assert accept_fr_response["friend_request_status"] == "accepted" + """ + Test accepting a friend request returns friendship dict. + """ + # Create another user and send friend request + other_email = "accept@user.com" + other_client = LoggedInClient(client.client, + email=other_email, + password="test", + username="accept", + learned_language="de") + + # Get users + sender_user = User.find(client.email) + other_user = User.find(other_email) + + # Send friend request + fr_response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert fr_response["friend_request_status"] == "pending" + + # User other client to accept friend request + accept_fr_response = other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + assert accept_fr_response["friend_request_status"] == "accepted" def test_reject_friend_request_success(client: LoggedInClient): """ - Test rejecting a friend request returns success message. - """ + Test rejecting a friend request returns success message. + """ other_email = "reject@user.com" other_client = LoggedInClient(client.client, email=other_email, @@ -42,7 +42,7 @@ def test_reject_friend_request_success(client: LoggedInClient): # Find users and ids other_user = User.find(other_email) sender_user = User.find(client.email) - + # Act: Send friend request and reject it client.post("/send_friend_request", json={"receiver_id": other_user.id}) response = other_client.post("/reject_friend_request", json={"sender_id": sender_user.id}) @@ -51,66 +51,66 @@ def test_reject_friend_request_success(client: LoggedInClient): assert response.get("success") is True def test_delete_friend_request_success(client: LoggedInClient): - """ - Test deleting a friend request returns 'True' or similar. - """ - # Create another user and send friend request - other_email = "delete@user.com" - user_data = dict(password="test", username="delete", learned_language="de") - client.post(f"/add_user/{other_email}", data=user_data) + """ + Test deleting a friend request returns 'True' or similar. + """ + # Create another user and send friend request + other_email = "delete@user.com" + user_data = dict(password="test", username="delete", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) - other_user = User.find(other_email) - client.post("/send_friend_request", json={"receiver_id": other_user.id}) - response = client.post("/delete_friend_request", json={"receiver_id": other_user.id}) - assert "True" in str(response) or response is True + other_user = User.find(other_email) + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + response = client.post("/delete_friend_request", json={"receiver_id": other_user.id}) + assert "True" in str(response) or response is True def test_delete_friend_request_invalid_receiver(client: LoggedInClient): - """ - Test deleting a friend request with invalid receiver returns error. - """ - response = client.post("/delete_friend_request", json={"receiver_id": 999999}) - assert response.get("success") is False + """ + Test deleting a friend request with invalid receiver returns error. + """ + response = client.post("/delete_friend_request", json={"receiver_id": 999999}) + assert response.get("success") is False def test_send_friend_request_success(client: LoggedInClient): - """ - Test sending a friend request to another user returns expected dict. - """ - # Create another user - other_email = "other@user.com" - user_data = dict(password="test", username="other", learned_language="de") - client.post(f"/add_user/{other_email}", data=user_data) - from zeeguu.core.model import User - other_user = User.find(other_email) - # Send friend request - response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) - assert isinstance(response, dict) - assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id + """ + Test sending a friend request to another user returns expected dict. + """ + # Create another user + other_email = "other@user.com" + user_data = dict(password="test", username="other", learned_language="de") + client.post(f"/add_user/{other_email}", data=user_data) + from zeeguu.core.model import User + other_user = User.find(other_email) + # Send friend request + response = client.post("/send_friend_request", json={"receiver_id": other_user.id}) + assert isinstance(response, dict) + assert response["sender"]["id"] == other_user.id or response["receiver"]["id"] == other_user.id def test_send_friend_request_to_self(client: LoggedInClient): - """ - Test sending a friend request to self returns error. - """ - from zeeguu.core.model import User - user = User.find(client.email) - response = client.post("/send_friend_request", json={"receiver_id": user.id}) - assert b"cannot send friend request to yourself" in response or "cannot send friend request to yourself" in str(response) + """ + Test sending a friend request to self returns error. + """ + from zeeguu.core.model import User + user = User.find(client.email) + response = client.post("/send_friend_request", json={"receiver_id": user.id}) + assert b"cannot send friend request to yourself" in response or "cannot send friend request to yourself" in str(response) def test_get_friend_requests(client: LoggedInClient): - """ - Test the /get_friend_requests endpoint returns a list (empty or not). - """ - response = client.get("/get_friend_requests") - assert isinstance(response, list) + """ + Test the /get_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_friend_requests") + assert isinstance(response, list) def test_get_pending_friend_requests(client: LoggedInClient): - """ - Test the /get_pending_friend_requests endpoint returns a list (empty or not). - """ - response = client.get("/get_pending_friend_requests") - assert isinstance(response, list) + """ + Test the /get_pending_friend_requests endpoint returns a list (empty or not). + """ + response = client.get("/get_pending_friend_requests") + assert isinstance(response, list) def test_unfriend_success(client: LoggedInClient): other_email = "unfriend@user.com" @@ -121,7 +121,7 @@ def test_unfriend_success(client: LoggedInClient): learned_language="de") sender_user = User.find(client.email) other_user = User.find(other_email) - + # Send and accept friend request client.post("/send_friend_request", json={"receiver_id": other_user.id}) other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) @@ -133,16 +133,16 @@ def test_unfriend_success(client: LoggedInClient): assert response.get("success") is True def test_search_users(client: LoggedInClient): - """ - Test search_users returns a list. - """ - response = client.get("/search_users/test") - assert isinstance(response, list) + """ + Test search_users returns a list. + """ + response = client.get("/search_users/test") + assert isinstance(response, list) def test_get_friends(client: LoggedInClient): - """ - Test the /get_friends endpoint returns a list (empty or not). - """ - response = client.get("/get_friends") - assert isinstance(response, list) + """ + Test the /get_friends endpoint returns a list (empty or not). + """ + response = client.get("/get_friends") + assert isinstance(response, list) From e4759f3cbc98846a1739e410a7de9b10b66940cd Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:10:56 +0100 Subject: [PATCH 058/142] Deleted print statement --- zeeguu/core/account_management/user_account_creation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zeeguu/core/account_management/user_account_creation.py b/zeeguu/core/account_management/user_account_creation.py index b9c63ecab..a93acd042 100644 --- a/zeeguu/core/account_management/user_account_creation.py +++ b/zeeguu/core/account_management/user_account_creation.py @@ -59,7 +59,6 @@ def create_account( learned_language = Language.find_or_create(learned_language_code) native_language = Language.find_or_create(native_language_code) - print(f"this is the username:{username}") new_user = User( email, username, From a8e32bb48e8e40ab5769bb804309a8f5ebb99f11 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:21:56 +0100 Subject: [PATCH 059/142] Fixed indentation and added logging for friends endpoints --- zeeguu/api/endpoints/friends.py | 26 ++++++++++++++++++-------- zeeguu/core/model/friend_request.py | 29 ++++++++++++++--------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 84c2af855..bfbe89673 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -7,13 +7,9 @@ from sqlalchemy.orm.exc import NoResultFound from zeeguu.api.utils.abort_handling import make_error from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.logging import log, debug, warning, critical from . import api -# import re -# from langdetect import detect -# import json -# from zeeguu.logging import log - # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) # --------------------------------------------------------------------------- @@ -24,7 +20,9 @@ def get_friends(): Get all friends of current user with flask.g.user_id """ friends = Friend.get_friends(flask.g.user_id) - return json_result(_serialize_users(friends)) + result = _serialize_users(friends) + log(f"get_friends: user_id={flask.g.user_id} has {len(result)} friends") + return json_result(result) # --------------------------------------------------------------------------- @@ -69,6 +67,7 @@ def send_friend_request(): 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: @@ -76,8 +75,10 @@ def send_friend_request(): response = _serialize_friend_request(friend_request) return json_result(response) except ValueError as e: + log(f"send_friend_request: error sending friend request from user_id={sender_id} to user_id={receiver_id} - {str(e)}") return make_error(400, str(e)) except NoResultFound: + log(f"send_friend_request: user not found for user_id={receiver_id}") return make_error(404, "User not found") @@ -95,6 +96,7 @@ def delete_friend_request(): status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: + log(f"delete_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") return make_error(status_code, error) is_deleted = FriendRequest.delete_friend_request(sender_id, receiver_id) @@ -115,12 +117,14 @@ def accept_friend_request(): print(f"sender_id: {sender_id}") status_code, error = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: + log(f"accept_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error}") return make_error(status_code, error) friendship = FriendRequest.accept_friend_request(sender_id, receiver_id) if friendship is None: + log(f"accept_friend_request: no friend request found from user_id={sender_id} to user_id={receiver_id}") return make_error(404, "No friend request found to accept") - + response = _serialize_friendship(friendship) return json_result(response) @@ -139,6 +143,7 @@ def reject_friend_request(): status_code, error_message = _is_friend_request_valid(sender_id, receiver_id) if status_code >= 400: + log(f"reject_friend_request: invalid request from user_id={sender_id} to user_id={receiver_id} - {error_message}") return make_error(status_code, error_message) is_rejected = FriendRequest.reject_friend_request(sender_id, receiver_id) @@ -158,9 +163,12 @@ def unfriend(): 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}) @@ -179,9 +187,11 @@ def search_by_username(username): Search for users with for the current user """ if not username or username.strip() == "": + log(f"search_users: empty username search from user_id={flask.g.user_id}") return make_error(400, "Username cannot be empty") result = Friend.search_users(flask.g.user_id, username) + log(f"search_users: user_id={flask.g.user_id} searched for username='{username}' and found {len(result)} results") return json_result(result) diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 03f94753e..6f9703881 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -151,21 +151,20 @@ def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: @classmethod def accept_friend_request(cls, sender_id: int, receiver_id: int): - try: - # Find the pending request - cls.delete_friend_request(sender_id, receiver_id) - # Optionally create a friendship in your friends table - friendship = Friend.add_friendship(sender_id, receiver_id) - return friendship - except NoResultFound: - return False + try: + # Find the pending request + cls.delete_friend_request(sender_id, receiver_id) + # Optionally create a friendship in your friends table + friendship = Friend.add_friendship(sender_id, receiver_id) + return friendship + except NoResultFound: + return None @classmethod def reject_friend_request(cls, sender_id: int, receiver_id: int): - try: - - # We just delete the friend request from the database - is_deleted = cls.delete_friend_request(sender_id, receiver_id) - return is_deleted - except NoResultFound: - return False \ No newline at end of file + try: + # We just delete the friend request from the database + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + return is_deleted + except NoResultFound: + return False \ No newline at end of file From 5bf0f68f2ed72dff0b77a58f9532a202eb22804c Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:25:05 +0100 Subject: [PATCH 060/142] Added line --- zeeguu/api/endpoints/friends.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index bfbe89673..108697029 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -39,6 +39,7 @@ def get_friend_requests(): result = [_serialize_friend_request(req) for req in friendRequest] return json_result(result) +# --------------------------------------------------------------------------- @api.route("/get_pending_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain From cfd8d6c8edc82f1fb1a457f5ce05613c4ab4fc27 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:26:27 +0100 Subject: [PATCH 061/142] adjusted test --- zeeguu/api/test/test_friends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 64d5b8047..3d09b83fa 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -95,7 +95,7 @@ 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 b"cannot send friend request to yourself" in response or "cannot send friend request to yourself" in str(response) + assert "cannot send friend request to yourself" in str(response) def test_get_friend_requests(client: LoggedInClient): """ From 577d8f14a1fe2a538b54d288d30bf2616d111fd1 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 10 Mar 2026 13:36:53 +0100 Subject: [PATCH 062/142] updated the accept_friend_request method --- zeeguu/core/model/friend_request.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/zeeguu/core/model/friend_request.py b/zeeguu/core/model/friend_request.py index 6f9703881..8089da40f 100644 --- a/zeeguu/core/model/friend_request.py +++ b/zeeguu/core/model/friend_request.py @@ -139,9 +139,9 @@ def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: """Delete a friend request between sender and receiver.""" try: fr = db.session.query(cls).filter_by( - sender_id=sender_id, - receiver_id=receiver_id, - status="pending" # usually only pending requests can be deleted + sender_id=sender_id, + receiver_id=receiver_id, + status="pending" # usually only pending requests can be deleted ).one() db.session.delete(fr) db.session.commit() @@ -153,8 +153,11 @@ def delete_friend_request(cls, sender_id: int, receiver_id: int)->bool: def accept_friend_request(cls, sender_id: int, receiver_id: int): try: # Find the pending request - cls.delete_friend_request(sender_id, receiver_id) - # Optionally create a friendship in your friends table + is_deleted = cls.delete_friend_request(sender_id, receiver_id) + if not is_deleted: + raise None # If the request was not found or could not be deleted, we cannot accept it + + # Create the friendship record in the database friendship = Friend.add_friendship(sender_id, receiver_id) return friendship except NoResultFound: From 08cae1f334aca907617b798fd2afbb9bd95fee45 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 11 Mar 2026 11:06:48 +0100 Subject: [PATCH 063/142] Working on friend streak feature --- zeeguu/core/model/friend.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 75d4291b2..d05612caa 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import relationship from zeeguu.core.model.db import db from zeeguu.core.model.user import User # assuming you have a User model - +from datetime import datetime class Friend(db.Model): @@ -13,6 +13,41 @@ class Friend(db.Model): user_id = Column(Integer, ForeignKey("user.id"), nullable=False) friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) created_at = Column(DateTime, default=func.now()) + friend_streak = Column(Integer, default=0) # Tracks streak with this friend + + + def update_friend_streak(self): + """ + Update friend_streak if both users practiced today or on consecutive days. + Requires UserLanguage.last_practiced for both users. + """ + from zeeguu.core.model.user_language import UserLanguage + now = func.now() + user_lang = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).first() + friend_lang = UserLanguage.query.filter(UserLanguage.user_id == self.friend_id).first() + if not user_lang or not friend_lang: + self.friend_streak = 0 + if db.session: + db.session.add(self) + db.session.commit() + return + + user_date = user_lang.last_practiced.date() if user_lang.last_practiced else None + friend_date = friend_lang.last_practiced.date() if friend_lang.last_practiced else None + today = datetime.now().date() + # Both practiced today + if user_date == today and friend_date == today: + # Check if yesterday was also a streak day + yesterday = today - datetime.timedelta(days=1) + if user_date == yesterday and friend_date == yesterday: + self.friend_streak = (self.friend_streak or 0) + 1 + else: + self.friend_streak = 1 + else: + self.friend_streak = 1 + if db.session: + db.session.add(self) + db.session.commit() # Explicit relationships with primaryjoin user = relationship( From 5d58ca9e2e1f33f95767eccb6c95ca38148f98be Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 11 Mar 2026 13:30:32 +0100 Subject: [PATCH 064/142] Fixed issue in the migration script --- tools/migrations/26-02-28--insert_default_badges.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/migrations/26-02-28--insert_default_badges.sql b/tools/migrations/26-02-28--insert_default_badges.sql index 5b2b9d1eb..35b9858a2 100644 --- a/tools/migrations/26-02-28--insert_default_badges.sql +++ b/tools/migrations/26-02-28--insert_default_badges.sql @@ -1,6 +1,6 @@ -- tools/migrations/26-02-28--insert_default_badge.sql -INSERT INTO badge (id, code, name, description, is_hidden) +INSERT INTO badge (id, code, name, description) VALUES (1, 'TRANSLATED_WORDS', 'Meaning Builder', 'Translate {target_value} unique words while reading.'), (2, 'CORRECT_EXERCISES', 'Practice Builder', 'Solve {target_value} exercises correctly.'), From 60d65f31ea92197a859a159d9070bc614b0a2bdb Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 11 Mar 2026 14:01:56 +0100 Subject: [PATCH 065/142] working on testing friends streak --- zeeguu/core/model/friend.py | 7 ++-- zeeguu/core/test/test_friends.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 zeeguu/core/test/test_friends.py diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index d05612caa..ad74cab80 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,8 +1,8 @@ -from sqlalchemy import Column, Integer, DateTime, Enum, ForeignKey, func, or_ +from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_ from sqlalchemy.orm import relationship from zeeguu.core.model.db import db from zeeguu.core.model.user import User # assuming you have a User model -from datetime import datetime +from datetime import datetime, timedelta class Friend(db.Model): @@ -22,7 +22,6 @@ def update_friend_streak(self): Requires UserLanguage.last_practiced for both users. """ from zeeguu.core.model.user_language import UserLanguage - now = func.now() user_lang = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).first() friend_lang = UserLanguage.query.filter(UserLanguage.user_id == self.friend_id).first() if not user_lang or not friend_lang: @@ -38,7 +37,7 @@ def update_friend_streak(self): # Both practiced today if user_date == today and friend_date == today: # Check if yesterday was also a streak day - yesterday = today - datetime.timedelta(days=1) + yesterday = today - timedelta(days=1) if user_date == yesterday and friend_date == yesterday: self.friend_streak = (self.friend_streak or 0) + 1 else: diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py new file mode 100644 index 000000000..d92021d10 --- /dev/null +++ b/zeeguu/core/test/test_friends.py @@ -0,0 +1,61 @@ +from datetime import datetime, timedelta + +import zeeguu.core +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.user_language import UserLanguage +from zeeguu.core.test.model_test_mixin import ModelTestMixIn +from zeeguu.core.test.rules.user_rule import UserRule + +session = zeeguu.core.model.db.session + + +class FriendTest(ModelTestMixIn): + def setUp(self): + super().setUp() + self.user = UserRule().user + self.friend_user = UserRule().user + self.friendship = Friend(user_id=self.user.id, friend_id=self.friend_user.id) + session.add(self.friendship) + session.commit() + + def _set_last_practiced(self, user, practiced_at): + user_language = UserLanguage.find_or_create(session, user, user.learned_language) + user_language.last_practiced = practiced_at + session.add(user_language) + session.commit() + + def test_update_friend_streak_resets_to_zero_without_user_languages(self): + self.friendship.friend_streak = 7 + session.add(self.friendship) + session.commit() + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 0 + + def test_update_friend_streak_sets_to_one_if_both_practiced_today(self): + now = datetime.now() + self._set_last_practiced(self.user, now) + self._set_last_practiced(self.friend_user, now) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 1 + + def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=1)) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 1 + + def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=1)) + + self.friendship.update_friend_streak() + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 1 + From 2a492bfe41f46776f2b583741d8eaf5b148ad53a Mon Sep 17 00:00:00 2001 From: gabortodor Date: Wed, 11 Mar 2026 15:47:15 +0100 Subject: [PATCH 066/142] Refactored badges migration scripts + endpoint prefix --- tools/migrations/26-02-19--add_badges.sql | 10 ++++++++++ .../26-03-04--add_user_badge_progress_table.sql | 11 ----------- zeeguu/api/endpoints/badges.py | 6 +++--- 3 files changed, 13 insertions(+), 14 deletions(-) delete mode 100644 tools/migrations/26-03-04--add_user_badge_progress_table.sql diff --git a/tools/migrations/26-02-19--add_badges.sql b/tools/migrations/26-02-19--add_badges.sql index 66e9a8ff5..019733b81 100644 --- a/tools/migrations/26-02-19--add_badges.sql +++ b/tools/migrations/26-02-19--add_badges.sql @@ -28,4 +28,14 @@ CREATE TABLE user_badge_level ( UNIQUE(user_id, badge_level_id), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (badge_level_id) REFERENCES badge_level(id) +); + +CREATE TABLE user_badge_progress ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + badge_id INT NOT NULL, + current_value INT NOT NULL, + UNIQUE(user_id, badge_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (badge_id) REFERENCES badge(id) ); \ No newline at end of file diff --git a/tools/migrations/26-03-04--add_user_badge_progress_table.sql b/tools/migrations/26-03-04--add_user_badge_progress_table.sql deleted file mode 100644 index 37c811794..000000000 --- a/tools/migrations/26-03-04--add_user_badge_progress_table.sql +++ /dev/null @@ -1,11 +0,0 @@ --- tools/migrations/26-03-04--add_user_badge_progress_table.sql - -CREATE TABLE user_badge_progress ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - badge_id INT NOT NULL, - current_value INT NOT NULL, - UNIQUE(user_id, badge_id), - FOREIGN KEY (user_id) REFERENCES user(id), - FOREIGN KEY (badge_id) REFERENCES badge(id) -); \ No newline at end of file diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 8f430a0a5..d86fa83e7 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -11,7 +11,7 @@ # --------------------------------------------------------------------------- -@api.route("/count_not_shown_badges", methods=["GET"]) +@api.route("/badges/count_not_shown", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session @@ -24,7 +24,7 @@ def get_not_shown_user_badge_levels(): # --------------------------------------------------------------------------- -@api.route("/get_user_badges", methods=["GET"]) +@api.route("/badges", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session @@ -65,7 +65,7 @@ def get_badges_for_user(): return json_result(result) # --------------------------------------------------------------------------- -@api.route("/update_not_shown_badges", methods=["POST"]) +@api.route("/badges/update_not_shown", methods=["POST"]) # --------------------------------------------------------------------------- @cross_domain @requires_session From 0ecb0a634007373150001b1d2b0e0157cca8a7ea Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 11 Mar 2026 17:17:26 +0100 Subject: [PATCH 067/142] Working on the friends streak functinoality --- zeeguu/core/model/friend.py | 35 +++++++++++++++++------- zeeguu/core/test/test_friends.py | 46 +++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index ad74cab80..bf719ccc9 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import relationship from zeeguu.core.model.db import db from zeeguu.core.model.user import User # assuming you have a User model -from datetime import datetime, timedelta +from datetime import datetime, timedelta, UTC class Friend(db.Model): @@ -18,22 +18,36 @@ class Friend(db.Model): def update_friend_streak(self): """ - Update friend_streak if both users practiced today or on consecutive days. - Requires UserLanguage.last_practiced for both users. + Update friend_streak based on both users' most recent practice in any language. + Uses the latest last_practiced date across all UserLanguage records for each user. """ from zeeguu.core.model.user_language import UserLanguage - user_lang = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).first() - friend_lang = UserLanguage.query.filter(UserLanguage.user_id == self.friend_id).first() - if not user_lang or not friend_lang: + from zeeguu.core.model.user import User + user = User.query.get(self.user_id) + friend = User.query.get(self.friend_id) + if not user or not friend: self.friend_streak = 0 if db.session: db.session.add(self) db.session.commit() return - - user_date = user_lang.last_practiced.date() if user_lang.last_practiced else None - friend_date = friend_lang.last_practiced.date() if friend_lang.last_practiced else None - today = datetime.now().date() + + # Get all UserLanguage records for each user + user_langs = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).all() + friend_langs = UserLanguage.query.filter(UserLanguage.user_id == self.friend_id).all() + + # Find the most recent last_practiced date for each user + user_date = None + friend_date = None + if user_langs: + user_date = max((ul.last_practiced for ul in user_langs if ul.last_practiced), default=None) + if friend_langs: + friend_date = max((ul.last_practiced for ul in friend_langs if ul.last_practiced), default=None) + + user_date = user_date.date() if user_date else None + friend_date = friend_date.date() if friend_date else None + today = datetime.now(UTC).date() # TODO: Use utc here? + # Both practiced today if user_date == today and friend_date == today: # Check if yesterday was also a streak day @@ -44,6 +58,7 @@ def update_friend_streak(self): self.friend_streak = 1 else: self.friend_streak = 1 + if db.session: db.session.add(self) db.session.commit() diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index d92021d10..80c4cc34f 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -24,14 +24,14 @@ def _set_last_practiced(self, user, practiced_at): session.add(user_language) session.commit() - def test_update_friend_streak_resets_to_zero_without_user_languages(self): + def test_update_friend_streak_resets_to_one_without_user_languages(self): self.friendship.friend_streak = 7 session.add(self.friendship) session.commit() self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 0 + assert self.friendship.friend_streak == 1 def test_update_friend_streak_sets_to_one_if_both_practiced_today(self): now = datetime.now() @@ -50,7 +50,7 @@ def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): assert self.friendship.friend_streak == 1 - def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): + def test_update_friend_streak_twice_only_increase_by_one(self): self._set_last_practiced(self.user, datetime.now()) self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=1)) @@ -59,3 +59,43 @@ def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): assert self.friendship.friend_streak == 1 + + def test_update_friend_streak_uses_learned_language(self): + # Setup: user and friend each have two languages, but only learned_language should count + from zeeguu.core.model.language import Language + + # Create two languages + lang1 = Language.find_or_create("en") + lang2 = Language.find_or_create("de") + + # Assign learned_language for both users + self.user.learned_language = lang1 + self.friend_user.learned_language = lang2 + session.add(self.user) + session.add(self.friend_user) + session.commit() + + # Practice in learned_language for both users + user_lang = UserLanguage.find_or_create(session, self.user, lang1) + friend_lang = UserLanguage.find_or_create(session, self.friend_user, lang2) + user_lang.last_practiced = datetime.now() + friend_lang.last_practiced = datetime.now() + session.add(user_lang) + session.add(friend_lang) + session.commit() + + # Add practice in a non-learned language (should not affect streak) + user_lang_other = UserLanguage.find_or_create(session, self.user, lang2) + friend_lang_other = UserLanguage.find_or_create(session, self.friend_user, lang1) + user_lang_other.last_practiced = datetime.now() - timedelta(days=5) + friend_lang_other.last_practiced = datetime.now() - timedelta(days=5) + session.add(user_lang_other) + session.add(friend_lang_other) + session.commit() + + # Act: update streak + self.friendship.update_friend_streak() + + # Assert: streak is 1, only learned_language practice is counted + assert self.friendship.friend_streak == 1 + From b069f55496ac20876d38b9700ae68d0e365f852c Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 11 Mar 2026 17:20:24 +0100 Subject: [PATCH 068/142] Update friend streak implemented --- zeeguu/core/model/user_language.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index ac369f248..29d13da66 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -155,6 +155,14 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) + # Update friend streaks for all friendships + from zeeguu.core.model.friend import Friend + friendships : list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak() + def reset_streak_if_broken(self, session=None): """ Reset streak to 0 if user hasn't practiced since yesterday. From be08968338aec37d02097dfe781632f95dffa6a8 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 11 Mar 2026 17:36:27 +0100 Subject: [PATCH 069/142] Always run the update_friends_streak --- zeeguu/core/model/user_language.py | 15 ++++++----- zeeguu/core/test/test_friends.py | 42 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 29d13da66..5c4f1b97e 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -155,13 +155,14 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) - # Update friend streaks for all friendships - from zeeguu.core.model.friend import Friend - friendships : list[Friend] = Friend.query.filter( - (Friend.user_id == user.id) | (Friend.friend_id == user.id) - ).all() - for friendship in friendships: - friendship.update_friend_streak() + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak() def reset_streak_if_broken(self, session=None): """ diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index 80c4cc34f..6c8d58bd8 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -24,6 +24,48 @@ def _set_last_practiced(self, user, practiced_at): session.add(user_language) session.commit() + def test_update_friend_streak_multiple_friends(self): + from zeeguu.core.model.language import Language + from zeeguu.core.model.user_language import UserLanguage + from zeeguu.core.model.friend import Friend + + # Create a language + lang = Language.find_or_create("en") + + # Create three users + user1 = self.user + user2 = UserRule().user + user3 = UserRule().user + + # Set up friendships: user1 ↔ user2, user1 ↔ user3 + friendship1 = Friend(user_id=user1.id, friend_id=user2.id) + friendship2 = Friend(user_id=user1.id, friend_id=user3.id) + session.add(friendship1) + session.add(friendship2) + session.commit() + + # Practice today for user1, user2, user3 + now = datetime.now() + ul1 = UserLanguage.find_or_create(session, user1, lang) + ul2 = UserLanguage.find_or_create(session, user2, lang) + ul3 = UserLanguage.find_or_create(session, user3, lang) + ul1.last_practiced = now + ul2.last_practiced = now + ul3.last_practiced = now + session.add(ul1) + session.add(ul2) + session.add(ul3) + session.commit() + + # Update streak for user1 (should update both friendships) + ul1.update_streak_if_needed(user1, session) + + # Refresh friendships from DB + session.refresh(friendship1) + session.refresh(friendship2) + + assert friendship1.friend_streak == 1 + assert friendship2.friend_streak == 1 def test_update_friend_streak_resets_to_one_without_user_languages(self): self.friendship.friend_streak = 7 session.add(self.friendship) From 2603bb5036c8a93ff49788133006f5bf01db8d1c Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 12 Mar 2026 08:38:42 +0100 Subject: [PATCH 070/142] Added created_at in the return value for get_user_details() --- zeeguu/core/model/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f023e264a..7bc39aa05 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -196,6 +196,7 @@ def details_as_dictionary(self): requires_email_verification=requires_email_verification, bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), + created_at=self.created_at, ) for each in UserLanguage.query.filter_by(user=self): From a6389ab52bf2cad20044a44497c8db64c5e88725 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 09:57:42 +0100 Subject: [PATCH 071/142] working on get friends and friend_streak --- zeeguu/api/endpoints/friends.py | 13 ++++++++---- zeeguu/core/model/friend.py | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 108697029..d74dd8321 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -19,12 +19,17 @@ def get_friends(): """ Get all friends of current user with flask.g.user_id """ - friends = Friend.get_friends(flask.g.user_id) - result = _serialize_users(friends) + friends_with_friendships = Friend.get_friends_with_friendship(flask.g.user_id) + result = [ + { + "user": _serialize_user(entry["user"]), + "friendship": _serialize_friendship(entry["friendship"]) + } + + for entry in friends_with_friendships + ] log(f"get_friends: user_id={flask.g.user_id} has {len(result)} friends") return json_result(result) - - # --------------------------------------------------------------------------- @api.route("/get_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index bf719ccc9..fecfaa5de 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -89,7 +89,40 @@ def get_friends(user_id): .all() ) return friends - + + @staticmethod + def get_friends_with_friendship(user_id: int): + """Return combined friend user + friendship data for the given user.""" + friendships : list[Friend] = Friend.query.filter( + (Friend.user_id == user_id) | (Friend.friend_id == user_id) + ).all() + + if not friendships: + return [] + + other_user_ids = [ + friendship.friend_id if friendship.user_id == user_id else friendship.user_id + for friendship in friendships + ] + + users = User.query.filter(User.id.in_(other_user_ids)).all() + users_by_id = {user.id: user for user in users} + + result = [] + for friendship in friendships: + + if friendship.user_id == user_id: + other_user_id = friendship.friend_id + else: + other_user_id = friendship.user_id + + friend_user = users_by_id.get(other_user_id) + if not friend_user: + continue + result.append({"user": friend_user, "friendship": friendship}) + + return result + @classmethod def remove_friendship(cls, user1_id: int, user2_id: int)->bool: # Look for friendship in either direction From a02b75d7935cc4fcdb03ffe623985ce49ea975a8 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 11:22:55 +0100 Subject: [PATCH 072/142] Added friend_streak and last_updated to the get_friends endpoint --- zeeguu/api/endpoints/friends.py | 2 ++ zeeguu/core/model/friend.py | 25 +++++++++++++++++-------- zeeguu/core/test/test_friends.py | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index d74dd8321..971b4553e 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -242,6 +242,8 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): "receiver_id": friendship.friend_id, "created_at": friendship.created_at, "friend_request_status": status, + "friend_streak": friendship.friend_streak, + "friend_streak_last_updated": friendship.friend_streak_last_updated.isoformat() if friendship.friend_streak_last_updated else None, } def _serialize_user(user: User): diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index fecfaa5de..f3d2671a2 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -14,6 +14,7 @@ class Friend(db.Model): friend_id = Column(Integer, ForeignKey("user.id"), nullable=False) created_at = Column(DateTime, default=func.now()) friend_streak = Column(Integer, default=0) # Tracks streak with this friend + friend_streak_last_updated = Column(DateTime, nullable=True) def update_friend_streak(self): @@ -46,18 +47,26 @@ def update_friend_streak(self): user_date = user_date.date() if user_date else None friend_date = friend_date.date() if friend_date else None - today = datetime.now(UTC).date() # TODO: Use utc here? + today = datetime.now(UTC).date() + yesterday = today - timedelta(days=1) + last_updated_date = ( + self.friend_streak_last_updated.date() + if self.friend_streak_last_updated + else None + ) - # Both practiced today + # If both practiced today, update at most once per day. if user_date == today and friend_date == today: - # Check if yesterday was also a streak day - yesterday = today - timedelta(days=1) - if user_date == yesterday and friend_date == yesterday: - self.friend_streak = (self.friend_streak or 0) + 1 - else: - self.friend_streak = 1 + if last_updated_date != today: + if last_updated_date == yesterday and (self.friend_streak or 0) > 0: + self.friend_streak = (self.friend_streak or 0) + 1 + else: + self.friend_streak = 1 + self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) else: + # Keep previous behavior: non-matching activity yields baseline streak. self.friend_streak = 1 + self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) if db.session: db.session.add(self) diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index 6c8d58bd8..8aa424fd3 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -101,6 +101,20 @@ def test_update_friend_streak_twice_only_increase_by_one(self): assert self.friendship.friend_streak == 1 + def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): + # Start from an active streak to verify reset behavior. + self.friendship.friend_streak = 4 + session.add(self.friendship) + session.commit() + + self._set_last_practiced(self.user, datetime.now()) + self._set_last_practiced(self.friend_user, datetime.now() - timedelta(days=2)) + + self.friendship.update_friend_streak() + + assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak_last_updated is not None + def test_update_friend_streak_uses_learned_language(self): # Setup: user and friend each have two languages, but only learned_language should count From 126ef9dfc3e6b52e2edc6aabdd5fa65075cab5aa Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 12:18:27 +0100 Subject: [PATCH 073/142] same response for search users and get_friends --- tools/migrations/26-02-24-a-add_username.sql | 2 +- zeeguu/api/endpoints/friends.py | 12 +++-- zeeguu/core/model/friend.py | 49 ++++++++++++++------ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/tools/migrations/26-02-24-a-add_username.sql b/tools/migrations/26-02-24-a-add_username.sql index 6e409f7b5..557f061d2 100644 --- a/tools/migrations/26-02-24-a-add_username.sql +++ b/tools/migrations/26-02-24-a-add_username.sql @@ -2,7 +2,7 @@ ALTER TABLE user ADD COLUMN username VARCHAR(50); -- This is maybe needed --- SET SQL_SAFE_UPDATES = 0; +SET SQL_SAFE_UPDATES = 0; -- Option 1 user_ UPDATE user diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 971b4553e..b7bae65d4 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -21,15 +21,17 @@ def get_friends(): """ friends_with_friendships = Friend.get_friends_with_friendship(flask.g.user_id) result = [ - { - "user": _serialize_user(entry["user"]), - "friendship": _serialize_friendship(entry["friendship"]) - } - + _serialize_user_with_friendship(entry["user"], entry["friendship"]) for entry in friends_with_friendships ] log(f"get_friends: user_id={flask.g.user_id} has {len(result)} friends") return json_result(result) + +def _serialize_user_with_friendship(user, friendship): + user_data = _serialize_user(user) + user_data["friendship"] = _serialize_friendship(friendship) + return user_data + # --------------------------------------------------------------------------- @api.route("/get_friend_requests", methods=["GET"]) # --------------------------------------------------------------------------- diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index f3d2671a2..cab391f9e 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -187,28 +187,51 @@ def search_users(current_user_id: int, term: str, limit: int = 20): ((FriendRequest.sender_id == user.id) & (FriendRequest.receiver_id == current_user_id)) ).order_by(FriendRequest.created_at.desc()).first() + + friendship_or_friend_request = Friend._get_friendship_or_friendrequest( + friendship, + friend_request) + results.append({ "user": { "id": user.id, "name": user.name, "username": user.username, "email": user.email, + "friendship": friendship_or_friend_request, }, - "friendship": { - "id": friendship.id if friendship else None, - "created_at": friendship.created_at.isoformat() if friendship and friendship.created_at else None, - } if friendship else None, - "friend_request": { - "id": friend_request.id if friend_request else None, - "sender_id": friend_request.sender_id if friend_request else None, - "receiver_id": friend_request.receiver_id if friend_request else None, - "status": friend_request.status if friend_request else None, - "created_at": friend_request.created_at.isoformat() if friend_request and friend_request.created_at else None, - } if friend_request else None }) return results - - + @staticmethod + def _get_friendship_or_friendrequest(friendship, friend_request): + + if friendship: + return { + "id": friendship.id, + "friend_streak": friendship.friend_streak, + "friend_streak_last_updated": ( + friendship.friend_streak_last_updated.isoformat() + if friendship.friend_streak_last_updated + else None + ), + "friend_request_status": "accepted", + "created_at": friendship.created_at.isoformat() if friendship.created_at else None, + } + elif friend_request: + return { + "id": friend_request.id, + "sender_id": friend_request.sender_id, # TODO: Are these nessesary + "receiver_id": friend_request.receiver_id, # TODO: are these nesessary + "friend_streak": 0, + "friend_streak_last_updated": None, + "friend_request_status": friend_request.status, + "created_at": ( + friend_request.created_at.isoformat() + if friend_request.created_at + else None + ), + } + def add_friendship(user_id: int, friend_id: int): """ Adds a friendship between two users using SQLAlchemy ORM. From 705f98349f246ee33fb6456434ed315fceb0efb2 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 12 Mar 2026 13:36:15 +0100 Subject: [PATCH 074/142] Added endpoint for fetching daily streak information for all active languages --- zeeguu/api/endpoints/daily_streak.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index 45cfa1896..a2ebfa11a 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -19,3 +19,22 @@ def get_daily_streak(): "max_streak": user_language.max_streak or 0, "max_streak_date": user_language.max_streak_date.strftime("%Y-%m-%d") if user_language.max_streak_date else None, }) + + +@api.route("/all_daily_streak", methods=["GET"]) +@cross_domain +@requires_session +def get_all_daily_streak(): + user = User.find_by_id(flask.g.user_id) + user_languages = UserLanguage.all_user_languages_for_user(user) + result = [] + for user_language in user_languages: + obj = { + "language": user_language.language.as_dictionary(), + "daily_streak": user_language.daily_streak or 0, + "max_streak": user_language.max_streak or 0, + "max_streak_date": user_language.max_streak_date.strftime( + "%Y-%m-%d") if user_language.max_streak_date else None + } + result.append(obj) + return json_result(result) From 85ec2ffbf2111cc0c88dc122837ea4bf0e8ea862 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 16:43:35 +0100 Subject: [PATCH 075/142] updated the friend_streak_logic --- zeeguu/core/model/friend.py | 9 ++++++--- zeeguu/core/model/user_language.py | 16 ++++++++-------- zeeguu/core/test/test_friends.py | 11 ++++++----- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index cab391f9e..59582589e 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -63,9 +63,12 @@ def update_friend_streak(self): else: self.friend_streak = 1 self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) - else: - # Keep previous behavior: non-matching activity yields baseline streak. - self.friend_streak = 1 + # Do not reset if one side has never practiced yet. + elif user_date is None or friend_date is None: + pass + # Reset only when at least one side has not practiced since before yesterday. + elif user_date < yesterday or friend_date < yesterday: + self.friend_streak = 0 self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) if db.session: diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5c4f1b97e..5275fd3f4 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -155,14 +155,14 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) - # Update friend streaks for all friendships even if the user's own - # daily streak does not change (e.g. repeated practice on same day). - from zeeguu.core.model.friend import Friend - friendships: list[Friend] = Friend.query.filter( - (Friend.user_id == user.id) | (Friend.friend_id == user.id) - ).all() - for friendship in friendships: - friendship.update_friend_streak() + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak() def reset_streak_if_broken(self, session=None): """ diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index 8aa424fd3..908b9c9f9 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -66,14 +66,15 @@ def test_update_friend_streak_multiple_friends(self): assert friendship1.friend_streak == 1 assert friendship2.friend_streak == 1 - def test_update_friend_streak_resets_to_one_without_user_languages(self): + + def test_update_friend_streak_does_not_reset_without_user_languages(self): self.friendship.friend_streak = 7 session.add(self.friendship) session.commit() self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 7 def test_update_friend_streak_sets_to_one_if_both_practiced_today(self): now = datetime.now() @@ -90,7 +91,7 @@ def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 def test_update_friend_streak_twice_only_increase_by_one(self): self._set_last_practiced(self.user, datetime.now()) @@ -99,7 +100,7 @@ def test_update_friend_streak_twice_only_increase_by_one(self): self.friendship.update_friend_streak() self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): # Start from an active streak to verify reset behavior. @@ -112,7 +113,7 @@ def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 assert self.friendship.friend_streak_last_updated is not None From 5df89624593c634b7b397317384440109880ae04 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 17:00:23 +0100 Subject: [PATCH 076/142] use local time instead of UTC (for now) --- zeeguu/core/model/friend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 59582589e..c84dc93f8 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -47,7 +47,7 @@ def update_friend_streak(self): user_date = user_date.date() if user_date else None friend_date = friend_date.date() if friend_date else None - today = datetime.now(UTC).date() + today = datetime.now().date() yesterday = today - timedelta(days=1) last_updated_date = ( self.friend_streak_last_updated.date() @@ -62,14 +62,14 @@ def update_friend_streak(self): self.friend_streak = (self.friend_streak or 0) + 1 else: self.friend_streak = 1 - self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) + self.friend_streak_last_updated = datetime.now() # Do not reset if one side has never practiced yet. elif user_date is None or friend_date is None: pass # Reset only when at least one side has not practiced since before yesterday. elif user_date < yesterday or friend_date < yesterday: self.friend_streak = 0 - self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) + self.friend_streak_last_updated = datetime.now() if db.session: db.session.add(self) From 3e716bb160b11a8d7e50282ed6ce890c085eea3e Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 17:36:32 +0100 Subject: [PATCH 077/142] Get friend details --- zeeguu/api/endpoints/user.py | 14 ++++++++++++++ zeeguu/core/model/friend.py | 22 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index cafa413af..20c0efd58 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -181,6 +181,20 @@ def get_user_details(): return json_result(details_dict) +@api.route("/get_user_details/", methods=["GET"]) +@cross_domain +@requires_session +def get_friend_details(friend_user_id): + """ + Return user details for a friend, if the requester is actually friends with them. + """ + user = User.find_by_id(flask.g.user_id) + from zeeguu.core.model.friend import Friend + friend_details = Friend.find_friend_details(user.id, friend_user_id) + if not friend_details: + return flask.jsonify({"error": "Not friends with this user or user not found."}) + return json_result(friend_details) + @api.route("/user_settings", methods=["POST"]) @cross_domain diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index cab391f9e..999ddb78d 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta, UTC class Friend(db.Model): - __tablename__ = "friends" __table_args__ = {"mysql_collate": "utf8_bin"} @@ -146,7 +145,26 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: return True return False - + + @classmethod + def find_friend_details(cls, user_id: int, friend_user_id: int): + """ + Return details_as_dictionary for friend_user_id if user_id and friend_user_id are friends. + Returns None if not friends or user not found. + """ + friendship = cls.query.filter( + ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | + ((cls.user_id == friend_user_id) & (cls.friend_id == user_id)) + ).first() + if not friendship: + return None + + from zeeguu.core.model.user import User + friend = User.find_by_id(friend_user_id) + if not friend: + return None + + return friend.details_as_dictionary() @staticmethod From 5d19b45bb7e2fbf10a656f6dd9609a049db80c44 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Fri, 13 Mar 2026 12:43:36 +0100 Subject: [PATCH 078/142] Added user avatars and returning them on /get_user_details --- .../migrations/26-03-13--add_user_avatar.sql | 9 +++++ zeeguu/core/model/user.py | 15 ++++++++ zeeguu/core/model/user_avatar.py | 38 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tools/migrations/26-03-13--add_user_avatar.sql create mode 100644 zeeguu/core/model/user_avatar.py diff --git a/tools/migrations/26-03-13--add_user_avatar.sql b/tools/migrations/26-03-13--add_user_avatar.sql new file mode 100644 index 000000000..9940400c6 --- /dev/null +++ b/tools/migrations/26-03-13--add_user_avatar.sql @@ -0,0 +1,9 @@ +CREATE TABLE user_avatar ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + image_name VARCHAR(100), + character_color VARCHAR(7), + background_color VARCHAR(7), + UNIQUE(user_id), + FOREIGN KEY (user_id) REFERENCES user(id) +); diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f53a63988..0c0570e82 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -193,6 +193,7 @@ def details_as_dictionary(self): from datetime import datetime from zeeguu.core.model import UserLanguage from zeeguu.core.model.bookmark import Bookmark + from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model.user_word import UserWord # Only require email verification for users created after this date @@ -216,9 +217,22 @@ def details_as_dictionary(self): and not self.email_verified ) + # Get the corresponding avatar details + user_avatar = UserAvatar.find(self.id) + user_avatar_dict = ( + dict( + image_name=user_avatar.image_name, + character_color=user_avatar.character_color, + background_color=user_avatar.background_color, + ) + if user_avatar + else None + ) + result = dict( email=self.email, name=self.name, + username=self.username, learned_language=self.learned_language.code, native_language=self.native_language.code, is_teacher=self.isTeacher(), @@ -230,6 +244,7 @@ def details_as_dictionary(self): bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), created_at=self.created_at, + user_avatar=user_avatar_dict, ) for each in UserLanguage.query.filter_by(user=self): diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py new file mode 100644 index 000000000..9b1051d1c --- /dev/null +++ b/zeeguu/core/model/user_avatar.py @@ -0,0 +1,38 @@ +from typing import Optional + +from zeeguu.core.model.db import db + + +class UserAvatar(db.Model): + """ + + """ + __tablename__ = "user_avatar" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + image_name = db.Column(db.String(100)) + character_color = db.Column(db.String(7)) + background_color = db.Column(db.String(7)) + + user = db.relationship("User") + + def __init__( + self, + user_id, + image_name=None, + character_color=None, + background_color=None + ): + self.user_id = user_id + self.image_name = image_name + self.character_color = character_color + self.background_color = background_color + + def __repr__(self): + return f"" + + @classmethod + def find(cls, user_id: int) -> Optional["UserAvatar"]: + """Return the corresponding avatar for the given user.""" + return cls.query.filter_by(user_id=user_id).one_or_none() From 6833e299367448c44e416741970b6ef91d3cbc59 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Fri, 13 Mar 2026 14:41:50 +0100 Subject: [PATCH 079/142] Username now also gets saved on /user_settings --- zeeguu/api/endpoints/user.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index cafa413af..2f1312730 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -197,6 +197,10 @@ def user_settings(): 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) From f9d6a5f70b4e6397baa83ca61057c4382216f147 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 14 Mar 2026 12:12:01 +0100 Subject: [PATCH 080/142] Added avatar saving for users --- zeeguu/api/endpoints/user.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 2f1312730..f6f0a1ea3 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -4,6 +4,7 @@ from zeeguu.api.endpoints.feature_toggles import features_for_user from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model import User from zeeguu.core.model.feedback_component import FeedbackComponent from zeeguu.core.model.url import Url @@ -189,9 +190,9 @@ def user_settings(): """ :return: OK for success """ - + user_id = flask.g.user_id data = flask.request.form - user = User.find_by_id(flask.g.user_id) + user = User.find_by_id(user_id) submitted_name = data.get("name", None) if submitted_name: @@ -217,6 +218,34 @@ def user_settings(): if submitted_email: user.email = submitted_email + 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) + + if any([ + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color + ]): + user_avatar = UserAvatar.find(user_id) + + if not user_avatar: + user_avatar = UserAvatar(user_id, + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color) + else: + if submitted_avatar_image_name: + user_avatar.image_name = submitted_avatar_image_name + + if submitted_avatar_character_color: + user_avatar.character_color = submitted_avatar_character_color + + if submitted_avatar_background_color: + user_avatar.background_color = submitted_avatar_background_color + + zeeguu.core.model.db.session.add(user_avatar) + zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" From f0228b03ab75c59a8aa7c6862be4a44613c6950e Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 14 Mar 2026 17:18:14 +0100 Subject: [PATCH 081/142] User avatar saving refactor --- zeeguu/api/endpoints/user.py | 27 +++------------------------ zeeguu/core/model/user_avatar.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 21a957a45..085f4cab2 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -228,31 +228,10 @@ def user_settings(): submitted_avatar_image_name = data.get("avatar_image_name", None) submitted_avatar_character_color = data.get("avatar_character_color", None) submitted_avatar_background_color = data.get("avatar_background_color", None) + user_avatar = UserAvatar.update_or_create(user_id, submitted_avatar_image_name, submitted_avatar_character_color, + submitted_avatar_background_color) - if any([ - submitted_avatar_image_name, - submitted_avatar_character_color, - submitted_avatar_background_color - ]): - user_avatar = UserAvatar.find(user_id) - - if not user_avatar: - user_avatar = UserAvatar(user_id, - submitted_avatar_image_name, - submitted_avatar_character_color, - submitted_avatar_background_color) - else: - if submitted_avatar_image_name: - user_avatar.image_name = submitted_avatar_image_name - - if submitted_avatar_character_color: - user_avatar.character_color = submitted_avatar_character_color - - if submitted_avatar_background_color: - user_avatar.background_color = submitted_avatar_background_color - - zeeguu.core.model.db.session.add(user_avatar) - + zeeguu.core.model.db.session.add(user_avatar) zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py index 9b1051d1c..1982a52c5 100644 --- a/zeeguu/core/model/user_avatar.py +++ b/zeeguu/core/model/user_avatar.py @@ -34,5 +34,22 @@ def __repr__(self): @classmethod def find(cls, user_id: int) -> Optional["UserAvatar"]: - """Return the corresponding avatar for the given user.""" + """ + Return the corresponding avatar for the given user. + """ return cls.query.filter_by(user_id=user_id).one_or_none() + + @classmethod + def update_or_create(cls, user_id, image_name, character_color, background_color): + """ + Update an existing avatar or create a new one for the specified user. + Does not commit. + """ + user_avatar = cls.find(user_id) + if user_avatar: + user_avatar.image_name = image_name + user_avatar.character_color = character_color + user_avatar.background_color = background_color + else: + user_avatar = UserAvatar(user_id, image_name, character_color, background_color) + return user_avatar From d00bf862560c014365b67de081f8db146a5d3475 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 15 Mar 2026 09:35:29 +0100 Subject: [PATCH 082/142] Fixed friendship migration script --- tools/migrations/26-02-24-friendship_system.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index e7b581b71..63b24dfbd 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -6,8 +6,8 @@ CREATE TABLE friends ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id), CONSTRAINT unique_friend_user UNIQUE (friend_id, user_id), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (friend_id) REFERENCES users(id) + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (friend_id) REFERENCES user(id) ); -- Friend requests table @@ -18,6 +18,6 @@ CREATE TABLE friend_requests ( status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, responded_at DATETIME, - FOREIGN KEY (sender_id) REFERENCES users(id), - FOREIGN KEY (receiver_id) REFERENCES users(id) + FOREIGN KEY (sender_id) REFERENCES user(id), + FOREIGN KEY (receiver_id) REFERENCES user(id) ); \ No newline at end of file From b484825c42c3efa68bf59d7fcce2722b63e741e1 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 15 Mar 2026 09:56:02 +0100 Subject: [PATCH 083/142] Minor badge improvements --- zeeguu/api/endpoints/badges.py | 11 ++++++----- zeeguu/core/badges/badge_progress.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index d86fa83e7..76fbd8a75 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -25,12 +25,13 @@ def get_not_shown_user_badge_levels(): # --------------------------------------------------------------------------- @api.route("/badges", methods=["GET"]) +@api.route("/badges/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_badges_for_user(): +def get_badges_for_user(user_id: int = None): """ - Retrieve all badges and their levels for the current user. + Retrieve all badges and their levels for the specified or current user. Each badge level includes achievement status and whether it has been shown. Returns: @@ -52,12 +53,12 @@ def get_badges_for_user(): "current_value": 10 }, ... ] """ - user_id = flask.g.user_id + used_user_id = user_id if user_id else flask.g.user_id badges = Badge.query.options(joinedload(Badge.badge_levels)).all() - user_badge_levels = UserBadgeLevel.find_all(user_id) + user_badge_levels = UserBadgeLevel.find_all(used_user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} - user_badge_progress = UserBadgeProgress.find_all(user_id) + user_badge_progress = UserBadgeProgress.find_all(used_user_id) progress_map = {ubp.badge_id: ubp for ubp in user_badge_progress} result = [serialize_badge(badge, achieved_map, progress_map) for badge in badges] diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py index 45e8ed2fa..578d9e81f 100644 --- a/zeeguu/core/badges/badge_progress.py +++ b/zeeguu/core/badges/badge_progress.py @@ -2,6 +2,7 @@ from zeeguu.core.model.badge import BadgeCode, Badge from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.user_badge_level import UserBadgeLevel +from zeeguu.logging import log def _award_badge_levels(db_session, badge_id: int, user_id: int, current_value: int) -> list[UserBadgeLevel]: @@ -44,6 +45,7 @@ def increment_badge_progress(db_session, badge_code: BadgeCode, user_id: int, in """ badge = Badge.find(badge_code) if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") return [] progress = UserBadgeProgress.create_or_increment( @@ -69,6 +71,7 @@ def update_badge_progress(db_session, badge_code: BadgeCode, user_id: int, curre """ badge = Badge.find(badge_code) if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") return [] progress = UserBadgeProgress.create_or_update( From 1412d02c7f8480f1f7d1f450c1d259a6d8b25cc1 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:06:45 +0100 Subject: [PATCH 084/142] Update friend streak and db session logic --- zeeguu/core/model/friend.py | 24 +++++++++--------------- zeeguu/core/model/user_language.py | 22 ++++++++++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index c84dc93f8..eff65e884 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,8 +1,8 @@ from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_ -from sqlalchemy.orm import relationship +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, UTC +from datetime import datetime class Friend(db.Model): @@ -17,21 +17,14 @@ class Friend(db.Model): friend_streak_last_updated = Column(DateTime, nullable=True) - def update_friend_streak(self): + def update_friend_streak(self, session=None, commit=True): """ Update friend_streak based on both users' most recent practice in any language. Uses the latest last_practiced date across all UserLanguage records for each user. """ from zeeguu.core.model.user_language import UserLanguage - from zeeguu.core.model.user import User - user = User.query.get(self.user_id) - friend = User.query.get(self.friend_id) - if not user or not friend: - self.friend_streak = 0 - if db.session: - db.session.add(self) - db.session.commit() - return + + session = session or object_session(self) or db.session # Get all UserLanguage records for each user user_langs = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).all() @@ -71,9 +64,10 @@ def update_friend_streak(self): self.friend_streak = 0 self.friend_streak_last_updated = datetime.now() - if db.session: - db.session.add(self) - db.session.commit() + if session: + session.add(self) + if commit: + session.commit() # Explicit relationships with primaryjoin user = relationship( diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5275fd3f4..98c3cf516 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -133,6 +133,7 @@ def update_streak_if_needed(self, user, session=None): Only updates once per day to minimize database writes. """ now = datetime.datetime.now() + active_session = session or db.session if not self.last_practiced or self.last_practiced.date() < now.date(): if not self.last_practiced: @@ -146,8 +147,7 @@ def update_streak_if_needed(self, user, session=None): self.last_practiced = now self._update_max_streak_if_needed() - if session: - session.add(self) + active_session.add(self) from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress daily_streak_badge_progress = max( @@ -155,14 +155,16 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) - # Update friend streaks for all friendships even if the user's own - # daily streak does not change (e.g. repeated practice on same day). - from zeeguu.core.model.friend import Friend - friendships: list[Friend] = Friend.query.filter( - (Friend.user_id == user.id) | (Friend.friend_id == user.id) - ).all() - for friendship in friendships: - friendship.update_friend_streak() + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak(session=active_session, commit=False) + + active_session.commit() def reset_streak_if_broken(self, session=None): """ From 8a9f25b60598cc4c277d049d93c441b4048f9985 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:15:18 +0100 Subject: [PATCH 085/142] try to fix broken env in pipeline --- .github/workflows/test.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32b9cf6dd..d1d67333d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,15 +48,18 @@ jobs: echo "ZEEGUU_RESOURCES_FOLDER=$ZEEGUU_RESOURCES_FOLDER" >> $GITHUB_ENV echo $ZEEGUU_RESOURCES_FOLDER - # Create and activate virtual environment - if [ ! -d "venv" ]; then - echo "Creating virtual environment..." + # Create or rebuild virtual environment when cache is stale/broken. + REBUILD_VENV=0 + if [ ! -x "venv/bin/python" ] || ! venv/bin/python -V > /dev/null 2>&1; then + echo "Creating or rebuilding virtual environment..." + rm -rf venv python -m venv venv + REBUILD_VENV=1 fi source venv/bin/activate - # Only install if cache missed or requirements changed - if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ]; then + # Install when cache missed, requirements changed, or venv was rebuilt. + if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ] || [ "$REBUILD_VENV" -eq 1 ]; then echo "Installing Python dependencies..." python -m pip install --upgrade pip pip install -r requirements.txt @@ -85,4 +88,4 @@ jobs: source venv/bin/activate export NLTK_DATA=$ZEEGUU_RESOURCES_FOLDER/nltk_data export DEV_SKIP_TRANSLATION=1 - pytest + python -m pytest From 281add95c030b2cf3494c1763c2fae42fde1a37f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:23:16 +0100 Subject: [PATCH 086/142] fix tests --- zeeguu/core/model/friend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index eff65e884..40e63cbf8 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ 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 +from datetime import datetime, timedelta class Friend(db.Model): From f191830150dc52440a091894d22ddcd8420525de Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 12:05:39 +0100 Subject: [PATCH 087/142] added friend streak to the migration --- tools/migrations/26-02-24-friendship_system.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index e7b581b71..953a614fc 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -4,6 +4,8 @@ CREATE TABLE friends ( user_id INT NOT NULL, friend_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + friend_streak INT DEFAULT 0, + friend_streak_last_updated DATETIME, CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id), CONSTRAINT unique_friend_user UNIQUE (friend_id, user_id), FOREIGN KEY (user_id) REFERENCES users(id), From 985913c1939f649bcd683f37af4f5e474d05fec5 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 12:05:52 +0100 Subject: [PATCH 088/142] added tests for the get_user_details for friends --- zeeguu/api/test/test_friends.py | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 3d09b83fa..f31d36750 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -146,3 +146,67 @@ def test_get_friends(client: LoggedInClient): response = client.get("/get_friends") assert isinstance(response, list) + +def test_get_user_details_returns_current_user_data(client: LoggedInClient): + """ + Test /get_user_details returns the logged-in user's details and feature flags. + """ + user = User.find(client.email) + + response = client.get("/get_user_details") + + assert isinstance(response, dict) + assert response["email"] == client.email + assert response["name"] == user.name + assert "learned_language" in response + assert "native_language" in response + assert "features" in response + + +def test_get_friend_details_returns_data_for_friend(client: LoggedInClient): + """ + Test /get_user_details/ returns details when users are friends. + """ + other_email = "friend-details@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="friend-details", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + response = client.get(f"/get_user_details/{other_user.id}") + + assert isinstance(response, dict) + assert response["email"] == other_email + assert response["name"] == other_user.name + assert "learned_language" in response + assert "native_language" in response + + +def test_get_friend_details_denies_non_friends(client: LoggedInClient): + """ + Test /get_user_details/ returns an error for non-friends. + """ + stranger_email = "not-a-friend@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="not-a-friend", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/get_user_details/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("error") == "Not friends with this user or user not found." + From 759e356bf0dea863297e667345bc0e51def50579 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 12 Mar 2026 08:38:42 +0100 Subject: [PATCH 089/142] Added created_at in the return value for get_user_details() --- zeeguu/core/model/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 6381c8ca6..f53a63988 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -229,6 +229,7 @@ def details_as_dictionary(self): requires_email_verification=requires_email_verification, bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), + created_at=self.created_at, ) for each in UserLanguage.query.filter_by(user=self): From e18d5b6e77fe15a2970c1e421396785605853295 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 12 Mar 2026 13:36:15 +0100 Subject: [PATCH 090/142] Added endpoint for fetching daily streak information for all active languages --- zeeguu/api/endpoints/daily_streak.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index 45cfa1896..a2ebfa11a 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -19,3 +19,22 @@ def get_daily_streak(): "max_streak": user_language.max_streak or 0, "max_streak_date": user_language.max_streak_date.strftime("%Y-%m-%d") if user_language.max_streak_date else None, }) + + +@api.route("/all_daily_streak", methods=["GET"]) +@cross_domain +@requires_session +def get_all_daily_streak(): + user = User.find_by_id(flask.g.user_id) + user_languages = UserLanguage.all_user_languages_for_user(user) + result = [] + for user_language in user_languages: + obj = { + "language": user_language.language.as_dictionary(), + "daily_streak": user_language.daily_streak or 0, + "max_streak": user_language.max_streak or 0, + "max_streak_date": user_language.max_streak_date.strftime( + "%Y-%m-%d") if user_language.max_streak_date else None + } + result.append(obj) + return json_result(result) From 9ca81a7c3953b36e23a26b282a25afa933776449 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Fri, 13 Mar 2026 12:43:36 +0100 Subject: [PATCH 091/142] Added user avatars and returning them on /get_user_details --- .../migrations/26-03-13--add_user_avatar.sql | 9 +++++ zeeguu/core/model/user.py | 15 ++++++++ zeeguu/core/model/user_avatar.py | 38 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tools/migrations/26-03-13--add_user_avatar.sql create mode 100644 zeeguu/core/model/user_avatar.py diff --git a/tools/migrations/26-03-13--add_user_avatar.sql b/tools/migrations/26-03-13--add_user_avatar.sql new file mode 100644 index 000000000..9940400c6 --- /dev/null +++ b/tools/migrations/26-03-13--add_user_avatar.sql @@ -0,0 +1,9 @@ +CREATE TABLE user_avatar ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + image_name VARCHAR(100), + character_color VARCHAR(7), + background_color VARCHAR(7), + UNIQUE(user_id), + FOREIGN KEY (user_id) REFERENCES user(id) +); diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index f53a63988..0c0570e82 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -193,6 +193,7 @@ def details_as_dictionary(self): from datetime import datetime from zeeguu.core.model import UserLanguage from zeeguu.core.model.bookmark import Bookmark + from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model.user_word import UserWord # Only require email verification for users created after this date @@ -216,9 +217,22 @@ def details_as_dictionary(self): and not self.email_verified ) + # Get the corresponding avatar details + user_avatar = UserAvatar.find(self.id) + user_avatar_dict = ( + dict( + image_name=user_avatar.image_name, + character_color=user_avatar.character_color, + background_color=user_avatar.background_color, + ) + if user_avatar + else None + ) + result = dict( email=self.email, name=self.name, + username=self.username, learned_language=self.learned_language.code, native_language=self.native_language.code, is_teacher=self.isTeacher(), @@ -230,6 +244,7 @@ def details_as_dictionary(self): bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), created_at=self.created_at, + user_avatar=user_avatar_dict, ) for each in UserLanguage.query.filter_by(user=self): diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py new file mode 100644 index 000000000..9b1051d1c --- /dev/null +++ b/zeeguu/core/model/user_avatar.py @@ -0,0 +1,38 @@ +from typing import Optional + +from zeeguu.core.model.db import db + + +class UserAvatar(db.Model): + """ + + """ + __tablename__ = "user_avatar" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + image_name = db.Column(db.String(100)) + character_color = db.Column(db.String(7)) + background_color = db.Column(db.String(7)) + + user = db.relationship("User") + + def __init__( + self, + user_id, + image_name=None, + character_color=None, + background_color=None + ): + self.user_id = user_id + self.image_name = image_name + self.character_color = character_color + self.background_color = background_color + + def __repr__(self): + return f"" + + @classmethod + def find(cls, user_id: int) -> Optional["UserAvatar"]: + """Return the corresponding avatar for the given user.""" + return cls.query.filter_by(user_id=user_id).one_or_none() From 7e1b29db8e523fab9a0b7afafb006797f4109b44 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Fri, 13 Mar 2026 14:41:50 +0100 Subject: [PATCH 092/142] Username now also gets saved on /user_settings --- zeeguu/api/endpoints/user.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 20c0efd58..9f409d0ee 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -211,6 +211,10 @@ def user_settings(): 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) From 16d1757dc1ba7a244a67197e30d8b23fe4c06635 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 14 Mar 2026 12:12:01 +0100 Subject: [PATCH 093/142] Added avatar saving for users --- zeeguu/api/endpoints/user.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 9f409d0ee..a86b68be6 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -4,6 +4,7 @@ from zeeguu.api.endpoints.feature_toggles import features_for_user from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.core.model.user_avatar import UserAvatar from zeeguu.core.model import User from zeeguu.core.model.feedback_component import FeedbackComponent from zeeguu.core.model.url import Url @@ -203,9 +204,9 @@ def user_settings(): """ :return: OK for success """ - + user_id = flask.g.user_id data = flask.request.form - user = User.find_by_id(flask.g.user_id) + user = User.find_by_id(user_id) submitted_name = data.get("name", None) if submitted_name: @@ -231,6 +232,34 @@ def user_settings(): if submitted_email: user.email = submitted_email + 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) + + if any([ + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color + ]): + user_avatar = UserAvatar.find(user_id) + + if not user_avatar: + user_avatar = UserAvatar(user_id, + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color) + else: + if submitted_avatar_image_name: + user_avatar.image_name = submitted_avatar_image_name + + if submitted_avatar_character_color: + user_avatar.character_color = submitted_avatar_character_color + + if submitted_avatar_background_color: + user_avatar.background_color = submitted_avatar_background_color + + zeeguu.core.model.db.session.add(user_avatar) + zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" From a1b2941deb16b6db0b66bb91edabcc38c33bfd68 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 11:19:55 +0100 Subject: [PATCH 094/142] Hide non-simplified articles from recommendations Articles without a simplified version at the user's level would open externally, which defeats the purpose of Zeeguu. Only show articles that are either simplified or teacher-uploaded. Co-Authored-By: Claude Opus 4.6 --- zeeguu/core/model/user_article.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index b3ed1a7f2..3bda0b5db 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -652,6 +652,11 @@ def article_infos(cls, user, articles, select_appropriate=True): if select_appropriate: article = cls.select_appropriate_article_for_user(user, article) + # Don't show original articles that aren't simplified — + # they'd open externally, which defeats the purpose + if not article.parent_article_id and not article.uploader_id: + continue + if article.id in seen_ids: continue seen_ids.add(article.id) From d445ab9e51fb680dea7949d664847469532d36ba Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 14:48:34 +0100 Subject: [PATCH 095/142] Allow setting password via /user_settings endpoint Supports the new upgrade flow where anonymous users set their password after email confirmation, rather than all at once. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/user.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index a86b68be6..8d430bd92 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -232,6 +232,10 @@ def user_settings(): 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) From 080028f0dc91cb8d84eac7ec773ed0dcef847311 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:22:37 +0100 Subject: [PATCH 096/142] Add atomic account upgrade to avoid email verification limbo state New endpoints: request_email_verification (sends code without changing user) and complete_account_upgrade (verifies code + upgrades atomically with email_verified=True). User stays anonymous until fully verified. Also add @allows_unverified to user_settings and user_preferences as a safety net for the old upgrade flow. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/accounts.py | 76 ++++++++++++++++++++++++ zeeguu/api/endpoints/user.py | 1 + zeeguu/api/endpoints/user_preferences.py | 3 +- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/accounts.py b/zeeguu/api/endpoints/accounts.py index ac6f1bf37..027c51f5e 100644 --- a/zeeguu/api/endpoints/accounts.py +++ b/zeeguu/api/endpoints/accounts.py @@ -183,6 +183,82 @@ def upgrade_anon_user(): return bad_request("Could not upgrade account") +@api.route("/request_email_verification", methods=["POST"]) +@cross_domain +@requires_session +def request_email_verification(): + """ + Send a verification code to an email WITHOUT changing the user account. + The user stays anonymous until complete_account_upgrade is called. + This avoids the "limbo state" where the user is no longer anonymous + but hasn't verified their email yet. + """ + import re + from zeeguu.core.emailer.email_confirmation import send_email_confirmation + + email = request.form.get("email", None) + if not email: + return bad_request("Email is required") + + email = email.lower().strip() + + if not re.match(User.EMAIL_VALIDATION_REGEX, email): + return bad_request("Invalid email format") + + if User.email_exists(email): + return bad_request("Email already in use") + + code = UniqueCode(email) + db_session.add(code) + db_session.commit() + send_email_confirmation(email, code) + + return "OK" + + +@api.route("/complete_account_upgrade", methods=["POST"]) +@cross_domain +@requires_session +def complete_account_upgrade(): + """ + Verify the email code and upgrade the anonymous account atomically. + The user goes directly from anonymous to fully verified — no limbo state. + """ + email = request.form.get("email", None) + code = request.form.get("code", None) + password = request.form.get("password", None) + + if not email or not code or not password: + return bad_request("Email, code, and password are required") + + # Verify the code + code_obj = UniqueCode.find_last_code(email) + if code_obj is None: + return bad_request("No verification code found. Please request a new one.") + if code_obj.is_expired(): + return bad_request("Code has expired. Please request a new one.") + if submitted_code_is_wrong(code_obj.code, code): + return bad_request("Invalid code") + + try: + user = User.find_by_id(flask.g.user_id) + user.upgrade_to_full_account(email, email, password) + user.email_verified = True # Already verified via code + + # Clean up codes + for c in UniqueCode.all_codes_for(email): + db_session.delete(c) + db_session.commit() + + return "OK" + + except ValueError as e: + return bad_request(str(e)) + except Exception as e: + log(f"Failed to complete account upgrade: {e}") + return bad_request("Could not upgrade account") + + @api.route("/confirm_email", methods=["POST"]) @cross_domain @requires_session diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 8d430bd92..522da638a 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -200,6 +200,7 @@ def get_friend_details(friend_user_id): @api.route("/user_settings", methods=["POST"]) @cross_domain @requires_session +@allows_unverified def user_settings(): """ :return: OK for success diff --git a/zeeguu/api/endpoints/user_preferences.py b/zeeguu/api/endpoints/user_preferences.py index 9dd95f550..79a1d5b31 100644 --- a/zeeguu/api/endpoints/user_preferences.py +++ b/zeeguu/api/endpoints/user_preferences.py @@ -2,7 +2,7 @@ import zeeguu.core from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified from . import api from ...core.model import UserPreference, User @@ -12,6 +12,7 @@ @api.route("/user_preferences", methods=["GET"]) @cross_domain @requires_session +@allows_unverified def user_preferences(): user = User.find_by_id(flask.g.user_id) return json_result(UserPreference.all_for_user(user)) From 6b38b55aecfdf7e8c26dbb52a4ffb5646e2e91a5 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:25:56 +0100 Subject: [PATCH 097/142] Add @allows_unverified to daily_streak endpoint Safety net for users stuck in the old upgrade limbo state. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/daily_streak.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index a2ebfa11a..928e97bfa 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -1,7 +1,7 @@ import flask from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified from . import api from ...core.model import User from ...core.model.user_language import UserLanguage @@ -11,6 +11,7 @@ @api.route("/daily_streak", methods=["GET"]) @cross_domain @requires_session +@allows_unverified def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) From 8cac12446d61709d642c2c7970b0f11d1a8fd578 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:28:06 +0100 Subject: [PATCH 098/142] Revert @allows_unverified from daily_streak, user_settings, user_preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed — the new atomic upgrade flow keeps users anonymous until fully verified, so they never hit the unverified limbo state. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/daily_streak.py | 3 +-- zeeguu/api/endpoints/user.py | 1 - zeeguu/api/endpoints/user_preferences.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index 928e97bfa..a2ebfa11a 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -1,7 +1,7 @@ import flask from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api from ...core.model import User from ...core.model.user_language import UserLanguage @@ -11,7 +11,6 @@ @api.route("/daily_streak", methods=["GET"]) @cross_domain @requires_session -@allows_unverified def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 522da638a..8d430bd92 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -200,7 +200,6 @@ def get_friend_details(friend_user_id): @api.route("/user_settings", methods=["POST"]) @cross_domain @requires_session -@allows_unverified def user_settings(): """ :return: OK for success diff --git a/zeeguu/api/endpoints/user_preferences.py b/zeeguu/api/endpoints/user_preferences.py index 79a1d5b31..9dd95f550 100644 --- a/zeeguu/api/endpoints/user_preferences.py +++ b/zeeguu/api/endpoints/user_preferences.py @@ -2,7 +2,7 @@ import zeeguu.core from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api from ...core.model import UserPreference, User @@ -12,7 +12,6 @@ @api.route("/user_preferences", methods=["GET"]) @cross_domain @requires_session -@allows_unverified def user_preferences(): user = User.find_by_id(flask.g.user_id) return json_result(UserPreference.all_for_user(user)) From cd42623ccd688341317943c905906f2882a693ef Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:45:01 +0100 Subject: [PATCH 099/142] Simplify verification codes to 4-digit numeric Much easier to type on mobile. 10,000 combinations with 15-min expiry is plenty secure for email verification. Co-Authored-By: Claude Opus 4.6 --- zeeguu/core/model/unique_code.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/zeeguu/core/model/unique_code.py b/zeeguu/core/model/unique_code.py index 6d1991630..0377e1e56 100644 --- a/zeeguu/core/model/unique_code.py +++ b/zeeguu/core/model/unique_code.py @@ -14,17 +14,14 @@ class UniqueCode(db.Model): __table_args__ = {"mysql_collate": "utf8_bin"} id = db.Column(db.Integer, primary_key=True) - code = db.Column(db.String(6)) # 6-char alphanumeric code + code = db.Column(db.String(6)) # 4-digit numeric code email = db.Column(db.String(255)) time = db.Column(db.DateTime) - # Alphanumeric characters excluding confusing ones (0/O, 1/I/L) - CODE_CHARS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" - def __init__(self, email): - # Generate 6-char alphanumeric code (30^6 = 729 million possibilities) - # With rate limiting and 15-min expiration, practically unbruteforceable - self.code = "".join(secrets.choice(self.CODE_CHARS) for _ in range(6)) + # 4-digit numeric code — easy to type on mobile + # 10,000 possibilities with 15-min expiry is plenty secure + self.code = str(secrets.randbelow(10000)).zfill(4) self.email = email self.time = datetime.now() From 45a112d2541eca4c625dcab4c605557160daeb0c Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 20:37:20 +0100 Subject: [PATCH 100/142] Fix starred/liked articles not showing due to recommendation filter The select_appropriate filter in article_infos skips non-simplified external articles, which is correct for recommendations but wrong for user-starred/liked articles that should always be returned. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/core/model/user_article.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index 3bda0b5db..6ab1fb5be 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -278,7 +278,7 @@ def all_starred_and_liked_articles_of_user_info(cls, user): if each.last_interaction() is not None ] - return cls.article_infos(user, articles, select_appropriate=True) + return cls.article_infos(user, articles, select_appropriate=False) @classmethod def exists(cls, obj): From 21ae2ffe9c9589dea77b94045ff46819fc656960 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 20:54:07 +0100 Subject: [PATCH 101/142] Add migration to fix exercise_report reason ENUM missing wrong_highlighting The production table was created without the wrong_highlighting value, causing report submissions with that reason to fail with data truncation. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/migrations/26-03-13--fix_exercise_report_reason_enum.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tools/migrations/26-03-13--fix_exercise_report_reason_enum.sql diff --git a/tools/migrations/26-03-13--fix_exercise_report_reason_enum.sql b/tools/migrations/26-03-13--fix_exercise_report_reason_enum.sql new file mode 100644 index 000000000..7abd1351e --- /dev/null +++ b/tools/migrations/26-03-13--fix_exercise_report_reason_enum.sql @@ -0,0 +1,2 @@ +ALTER TABLE exercise_report +MODIFY COLUMN reason ENUM('word_not_shown', 'wrong_highlighting', 'context_confusing', 'wrong_translation', 'context_wrong', 'other') NOT NULL; From d35a8bdcae3d6091ba930ad12573726fd16a2a39 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 21:17:22 +0100 Subject: [PATCH 102/142] Filter hidden articles from unfinished reading sessions Hidden articles were still returned by /get_unfinished_user_reading_sessions, causing them to reappear in "Continue where you left off" after refresh. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/api/endpoints/user.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 8d430bd92..5f0d5482d 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -147,6 +147,9 @@ def get_user_unfinished_reading_sessions(total_sessions: int = 1): art = Article.find_by_id(art_id) if art: + ua = UserArticle.find(user, art) + if ua and ua.hidden: + continue articles_to_fetch.append(art) session_data[art_id] = (date_read, last_reading_percentage) From dd1db8638c78d63074f17fa11eea2a77921d8f08 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 14 Mar 2026 17:18:14 +0100 Subject: [PATCH 103/142] User avatar saving refactor --- zeeguu/api/endpoints/user.py | 27 +++------------------------ zeeguu/core/model/user_avatar.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 5f0d5482d..da92a894d 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -242,31 +242,10 @@ def user_settings(): submitted_avatar_image_name = data.get("avatar_image_name", None) submitted_avatar_character_color = data.get("avatar_character_color", None) submitted_avatar_background_color = data.get("avatar_background_color", None) + user_avatar = UserAvatar.update_or_create(user_id, submitted_avatar_image_name, submitted_avatar_character_color, + submitted_avatar_background_color) - if any([ - submitted_avatar_image_name, - submitted_avatar_character_color, - submitted_avatar_background_color - ]): - user_avatar = UserAvatar.find(user_id) - - if not user_avatar: - user_avatar = UserAvatar(user_id, - submitted_avatar_image_name, - submitted_avatar_character_color, - submitted_avatar_background_color) - else: - if submitted_avatar_image_name: - user_avatar.image_name = submitted_avatar_image_name - - if submitted_avatar_character_color: - user_avatar.character_color = submitted_avatar_character_color - - if submitted_avatar_background_color: - user_avatar.background_color = submitted_avatar_background_color - - zeeguu.core.model.db.session.add(user_avatar) - + zeeguu.core.model.db.session.add(user_avatar) zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py index 9b1051d1c..1982a52c5 100644 --- a/zeeguu/core/model/user_avatar.py +++ b/zeeguu/core/model/user_avatar.py @@ -34,5 +34,22 @@ def __repr__(self): @classmethod def find(cls, user_id: int) -> Optional["UserAvatar"]: - """Return the corresponding avatar for the given user.""" + """ + Return the corresponding avatar for the given user. + """ return cls.query.filter_by(user_id=user_id).one_or_none() + + @classmethod + def update_or_create(cls, user_id, image_name, character_color, background_color): + """ + Update an existing avatar or create a new one for the specified user. + Does not commit. + """ + user_avatar = cls.find(user_id) + if user_avatar: + user_avatar.image_name = image_name + user_avatar.character_color = character_color + user_avatar.background_color = background_color + else: + user_avatar = UserAvatar(user_id, image_name, character_color, background_color) + return user_avatar From 590c5a6b49f48f74910fc771eec2e5f9570c8430 Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 15 Mar 2026 09:35:29 +0100 Subject: [PATCH 104/142] Fixed friendship migration script --- tools/migrations/26-02-24-friendship_system.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/migrations/26-02-24-friendship_system.sql b/tools/migrations/26-02-24-friendship_system.sql index 953a614fc..1952498d2 100644 --- a/tools/migrations/26-02-24-friendship_system.sql +++ b/tools/migrations/26-02-24-friendship_system.sql @@ -8,8 +8,8 @@ CREATE TABLE friends ( friend_streak_last_updated DATETIME, CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id), CONSTRAINT unique_friend_user UNIQUE (friend_id, user_id), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (friend_id) REFERENCES users(id) + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (friend_id) REFERENCES user(id) ); -- Friend requests table @@ -20,6 +20,6 @@ CREATE TABLE friend_requests ( status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, responded_at DATETIME, - FOREIGN KEY (sender_id) REFERENCES users(id), - FOREIGN KEY (receiver_id) REFERENCES users(id) + FOREIGN KEY (sender_id) REFERENCES user(id), + FOREIGN KEY (receiver_id) REFERENCES user(id) ); \ No newline at end of file From 3e2f61bf204007c3cb32ea20527eeedf551dc02d Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sun, 15 Mar 2026 09:56:02 +0100 Subject: [PATCH 105/142] Minor badge improvements --- zeeguu/api/endpoints/badges.py | 11 ++++++----- zeeguu/core/badges/badge_progress.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index d86fa83e7..76fbd8a75 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -25,12 +25,13 @@ def get_not_shown_user_badge_levels(): # --------------------------------------------------------------------------- @api.route("/badges", methods=["GET"]) +@api.route("/badges/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_badges_for_user(): +def get_badges_for_user(user_id: int = None): """ - Retrieve all badges and their levels for the current user. + Retrieve all badges and their levels for the specified or current user. Each badge level includes achievement status and whether it has been shown. Returns: @@ -52,12 +53,12 @@ def get_badges_for_user(): "current_value": 10 }, ... ] """ - user_id = flask.g.user_id + used_user_id = user_id if user_id else flask.g.user_id badges = Badge.query.options(joinedload(Badge.badge_levels)).all() - user_badge_levels = UserBadgeLevel.find_all(user_id) + user_badge_levels = UserBadgeLevel.find_all(used_user_id) achieved_map = {ubl.badge_level_id: ubl for ubl in user_badge_levels} - user_badge_progress = UserBadgeProgress.find_all(user_id) + user_badge_progress = UserBadgeProgress.find_all(used_user_id) progress_map = {ubp.badge_id: ubp for ubp in user_badge_progress} result = [serialize_badge(badge, achieved_map, progress_map) for badge in badges] diff --git a/zeeguu/core/badges/badge_progress.py b/zeeguu/core/badges/badge_progress.py index 45e8ed2fa..578d9e81f 100644 --- a/zeeguu/core/badges/badge_progress.py +++ b/zeeguu/core/badges/badge_progress.py @@ -2,6 +2,7 @@ from zeeguu.core.model.badge import BadgeCode, Badge from zeeguu.core.model.badge_level import BadgeLevel from zeeguu.core.model.user_badge_level import UserBadgeLevel +from zeeguu.logging import log def _award_badge_levels(db_session, badge_id: int, user_id: int, current_value: int) -> list[UserBadgeLevel]: @@ -44,6 +45,7 @@ def increment_badge_progress(db_session, badge_code: BadgeCode, user_id: int, in """ badge = Badge.find(badge_code) if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") return [] progress = UserBadgeProgress.create_or_increment( @@ -69,6 +71,7 @@ def update_badge_progress(db_session, badge_code: BadgeCode, user_id: int, curre """ badge = Badge.find(badge_code) if not badge: + log(f"[BADGE-ERROR] Cannot find badge entity with code='{badge_code}'") return [] progress = UserBadgeProgress.create_or_update( From e16ce7d6a8340a3070087e3c2edc65cf893f8c16 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 16:43:35 +0100 Subject: [PATCH 106/142] updated the friend_streak_logic --- zeeguu/core/model/friend.py | 9 ++++++--- zeeguu/core/model/user_language.py | 16 ++++++++-------- zeeguu/core/test/test_friends.py | 11 ++++++----- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 999ddb78d..dc83eac53 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -62,9 +62,12 @@ def update_friend_streak(self): else: self.friend_streak = 1 self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) - else: - # Keep previous behavior: non-matching activity yields baseline streak. - self.friend_streak = 1 + # Do not reset if one side has never practiced yet. + elif user_date is None or friend_date is None: + pass + # Reset only when at least one side has not practiced since before yesterday. + elif user_date < yesterday or friend_date < yesterday: + self.friend_streak = 0 self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) if db.session: diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5c4f1b97e..5275fd3f4 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -155,14 +155,14 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) - # Update friend streaks for all friendships even if the user's own - # daily streak does not change (e.g. repeated practice on same day). - from zeeguu.core.model.friend import Friend - friendships: list[Friend] = Friend.query.filter( - (Friend.user_id == user.id) | (Friend.friend_id == user.id) - ).all() - for friendship in friendships: - friendship.update_friend_streak() + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak() def reset_streak_if_broken(self, session=None): """ diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index 8aa424fd3..908b9c9f9 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -66,14 +66,15 @@ def test_update_friend_streak_multiple_friends(self): assert friendship1.friend_streak == 1 assert friendship2.friend_streak == 1 - def test_update_friend_streak_resets_to_one_without_user_languages(self): + + def test_update_friend_streak_does_not_reset_without_user_languages(self): self.friendship.friend_streak = 7 session.add(self.friendship) session.commit() self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 7 def test_update_friend_streak_sets_to_one_if_both_practiced_today(self): now = datetime.now() @@ -90,7 +91,7 @@ def test_update_friend_streak_sets_to_one_if_only_one_practiced_today(self): self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 def test_update_friend_streak_twice_only_increase_by_one(self): self._set_last_practiced(self.user, datetime.now()) @@ -99,7 +100,7 @@ def test_update_friend_streak_twice_only_increase_by_one(self): self.friendship.update_friend_streak() self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): # Start from an active streak to verify reset behavior. @@ -112,7 +113,7 @@ def test_update_friend_streak_resets_when_one_friend_does_not_practice(self): self.friendship.update_friend_streak() - assert self.friendship.friend_streak == 1 + assert self.friendship.friend_streak == 0 assert self.friendship.friend_streak_last_updated is not None From c27da609c64e2963eb95ec7873dfb09634e93646 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 12 Mar 2026 17:00:23 +0100 Subject: [PATCH 107/142] use local time instead of UTC (for now) --- zeeguu/core/model/friend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index dc83eac53..aa3c08dcf 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -46,7 +46,7 @@ def update_friend_streak(self): user_date = user_date.date() if user_date else None friend_date = friend_date.date() if friend_date else None - today = datetime.now(UTC).date() + today = datetime.now().date() yesterday = today - timedelta(days=1) last_updated_date = ( self.friend_streak_last_updated.date() @@ -61,14 +61,14 @@ def update_friend_streak(self): self.friend_streak = (self.friend_streak or 0) + 1 else: self.friend_streak = 1 - self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) + self.friend_streak_last_updated = datetime.now() # Do not reset if one side has never practiced yet. elif user_date is None or friend_date is None: pass # Reset only when at least one side has not practiced since before yesterday. elif user_date < yesterday or friend_date < yesterday: self.friend_streak = 0 - self.friend_streak_last_updated = datetime.now(UTC).replace(tzinfo=None) + self.friend_streak_last_updated = datetime.now() if db.session: db.session.add(self) From 1e1b889c06fe463d38850a50c3e93f598618b9ab Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:06:45 +0100 Subject: [PATCH 108/142] Update friend streak and db session logic --- zeeguu/core/model/friend.py | 24 +++++++++--------------- zeeguu/core/model/user_language.py | 22 ++++++++++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index aa3c08dcf..12c7e46b2 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -1,8 +1,8 @@ from sqlalchemy import Column, Integer, DateTime, ForeignKey, func, or_ -from sqlalchemy.orm import relationship +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, UTC +from datetime import datetime class Friend(db.Model): __tablename__ = "friends" @@ -16,21 +16,14 @@ class Friend(db.Model): friend_streak_last_updated = Column(DateTime, nullable=True) - def update_friend_streak(self): + def update_friend_streak(self, session=None, commit=True): """ Update friend_streak based on both users' most recent practice in any language. Uses the latest last_practiced date across all UserLanguage records for each user. """ from zeeguu.core.model.user_language import UserLanguage - from zeeguu.core.model.user import User - user = User.query.get(self.user_id) - friend = User.query.get(self.friend_id) - if not user or not friend: - self.friend_streak = 0 - if db.session: - db.session.add(self) - db.session.commit() - return + + session = session or object_session(self) or db.session # Get all UserLanguage records for each user user_langs = UserLanguage.query.filter(UserLanguage.user_id == self.user_id).all() @@ -70,9 +63,10 @@ def update_friend_streak(self): self.friend_streak = 0 self.friend_streak_last_updated = datetime.now() - if db.session: - db.session.add(self) - db.session.commit() + if session: + session.add(self) + if commit: + session.commit() # Explicit relationships with primaryjoin user = relationship( diff --git a/zeeguu/core/model/user_language.py b/zeeguu/core/model/user_language.py index 5275fd3f4..98c3cf516 100644 --- a/zeeguu/core/model/user_language.py +++ b/zeeguu/core/model/user_language.py @@ -133,6 +133,7 @@ def update_streak_if_needed(self, user, session=None): Only updates once per day to minimize database writes. """ now = datetime.datetime.now() + active_session = session or db.session if not self.last_practiced or self.last_practiced.date() < now.date(): if not self.last_practiced: @@ -146,8 +147,7 @@ def update_streak_if_needed(self, user, session=None): self.last_practiced = now self._update_max_streak_if_needed() - if session: - session.add(self) + active_session.add(self) from zeeguu.core.badges.badge_progress import BadgeCode, update_badge_progress daily_streak_badge_progress = max( @@ -155,14 +155,16 @@ def update_streak_if_needed(self, user, session=None): ) update_badge_progress(db.session, BadgeCode.STREAK_COUNT, user.id, daily_streak_badge_progress) - # Update friend streaks for all friendships even if the user's own - # daily streak does not change (e.g. repeated practice on same day). - from zeeguu.core.model.friend import Friend - friendships: list[Friend] = Friend.query.filter( - (Friend.user_id == user.id) | (Friend.friend_id == user.id) - ).all() - for friendship in friendships: - friendship.update_friend_streak() + # Update friend streaks for all friendships even if the user's own + # daily streak does not change (e.g. repeated practice on same day). + from zeeguu.core.model.friend import Friend + friendships: list[Friend] = Friend.query.filter( + (Friend.user_id == user.id) | (Friend.friend_id == user.id) + ).all() + for friendship in friendships: + friendship.update_friend_streak(session=active_session, commit=False) + + active_session.commit() def reset_streak_if_broken(self, session=None): """ From 9179b4bff67acd7a5e9cdfa680fb1e6b2b56b571 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:15:18 +0100 Subject: [PATCH 109/142] try to fix broken env in pipeline --- .github/workflows/test.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32b9cf6dd..d1d67333d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,15 +48,18 @@ jobs: echo "ZEEGUU_RESOURCES_FOLDER=$ZEEGUU_RESOURCES_FOLDER" >> $GITHUB_ENV echo $ZEEGUU_RESOURCES_FOLDER - # Create and activate virtual environment - if [ ! -d "venv" ]; then - echo "Creating virtual environment..." + # Create or rebuild virtual environment when cache is stale/broken. + REBUILD_VENV=0 + if [ ! -x "venv/bin/python" ] || ! venv/bin/python -V > /dev/null 2>&1; then + echo "Creating or rebuilding virtual environment..." + rm -rf venv python -m venv venv + REBUILD_VENV=1 fi source venv/bin/activate - # Only install if cache missed or requirements changed - if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ]; then + # Install when cache missed, requirements changed, or venv was rebuilt. + if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ] || [ "$REBUILD_VENV" -eq 1 ]; then echo "Installing Python dependencies..." python -m pip install --upgrade pip pip install -r requirements.txt @@ -85,4 +88,4 @@ jobs: source venv/bin/activate export NLTK_DATA=$ZEEGUU_RESOURCES_FOLDER/nltk_data export DEV_SKIP_TRANSLATION=1 - pytest + python -m pytest From 70735f7e38129e711e2d81a75d6c3e3bbb3fc9ad Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:23:16 +0100 Subject: [PATCH 110/142] fix tests --- zeeguu/core/model/friend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 12c7e46b2..22c6432f4 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ 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 +from datetime import datetime, timedelta class Friend(db.Model): __tablename__ = "friends" From 9fe62cb1eca4a1e5ee0f1fb9e6dea4b18d28d68f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 14:36:48 +0100 Subject: [PATCH 111/142] Working on get data for see friend user profile --- zeeguu/api/endpoints/badges.py | 8 +++++- zeeguu/api/endpoints/friends.py | 15 +++++++--- zeeguu/api/test/test_badges.py | 49 +++++++++++++++++++++++++++++++++ zeeguu/api/test/test_friends.py | 46 +++++++++++++++++++++++++++++++ zeeguu/core/model/friend.py | 21 ++++++++++---- 5 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 zeeguu/api/test/test_badges.py diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 76fbd8a75..1b4b7d60d 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -3,9 +3,11 @@ from zeeguu.core.model.user_badge_progress import UserBadgeProgress from zeeguu.core.model.badge_level import BadgeLevel +from zeeguu.api.utils.abort_handling import make_error from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from zeeguu.core.model.badge import Badge +from zeeguu.core.model.friend import Friend from zeeguu.core.model.user_badge_level import UserBadgeLevel from . import api, db_session @@ -53,7 +55,11 @@ def get_badges_for_user(user_id: int = None): "current_value": 10 }, ... ] """ - used_user_id = user_id if user_id else flask.g.user_id + requester_id = flask.g.user_id + used_user_id = user_id if user_id is not None else requester_id + + if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id): + return make_error(403, "You can only view badges for yourself or your friends.") badges = Badge.query.options(joinedload(Badge.badge_levels)).all() user_badge_levels = UserBadgeLevel.find_all(used_user_id) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index b7bae65d4..5c545e0bc 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -12,19 +12,26 @@ # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) +@api.route("/get_friends/", methods=["GET"]) # --------------------------------------------------------------------------- @cross_domain @requires_session -def get_friends(): +def get_friends(user_id: int = None): """ - Get all friends of current user with flask.g.user_id + Get all friends for the current user, or for a friend by user id. """ - friends_with_friendships = Friend.get_friends_with_friendship(flask.g.user_id) + requester_id = flask.g.user_id + used_user_id = user_id if user_id is not None else requester_id + + if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id): + return make_error(403, "You can only view friends for yourself or your friends.") + + friends_with_friendships = Friend.get_friends_with_friendship(used_user_id) result = [ _serialize_user_with_friendship(entry["user"], entry["friendship"]) for entry in friends_with_friendships ] - log(f"get_friends: user_id={flask.g.user_id} has {len(result)} friends") + 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, friendship): diff --git a/zeeguu/api/test/test_badges.py b/zeeguu/api/test/test_badges.py new file mode 100644 index 000000000..ba9ae08e2 --- /dev/null +++ b/zeeguu/api/test/test_badges.py @@ -0,0 +1,49 @@ +from fixtures import LoggedInClient, logged_in_client as client +from zeeguu.core.model import User + + +def test_get_badges_for_friend_user_id(client: LoggedInClient): + """ + Test /badges/ returns badge data when users are friends. + """ + other_email = "badges-friend@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="badges-friend", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + response = client.get(f"/badges/{other_user.id}") + + assert isinstance(response, list) + if response: + assert "badge_id" in response[0] + assert "levels" in response[0] + + +def test_get_badges_for_non_friend_user_denied(client: LoggedInClient): + """ + Test /badges/ denies access when users are not friends. + """ + stranger_email = "badges-private@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="badges-private", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/badges/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("message") == "You can only view badges for yourself or your friends." diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index f31d36750..557f40919 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -147,6 +147,52 @@ def test_get_friends(client: LoggedInClient): assert isinstance(response, list) +def test_get_friends_for_friend_user_id(client: LoggedInClient): + """ + Test /get_friends/ returns the friend's friends when users are friends. + """ + other_email = "friends-list@user.com" + other_client = LoggedInClient( + client.client, + email=other_email, + password="test", + username="friends-list", + learned_language="de", + ) + + sender_user = User.find(client.email) + other_user = User.find(other_email) + + client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + + response = client.get(f"/get_friends/{other_user.id}") + + assert isinstance(response, list) + friend_ids = [entry["id"] for entry in response] + assert sender_user.id in friend_ids + + +def test_get_friends_for_non_friend_user_denied(client: LoggedInClient): + """ + Test /get_friends/ denies access when users are not friends. + """ + stranger_email = "friends-private@user.com" + LoggedInClient( + client.client, + email=stranger_email, + password="test", + username="friends-private", + learned_language="de", + ) + stranger_user = User.find(stranger_email) + + response = client.get(f"/get_friends/{stranger_user.id}") + + assert isinstance(response, dict) + assert response.get("message") == "You can only view friends for yourself or your friends." + + def test_get_user_details_returns_current_user_data(client: LoggedInClient): """ Test /get_user_details returns the logged-in user's details and feature flags. diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 22c6432f4..e6e0f4a80 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -127,6 +127,21 @@ def get_friends_with_friendship(user_id: int): result.append({"user": friend_user, "friendship": friendship}) return result + + @classmethod + def are_friends(cls, user1_id: int, user2_id: int) -> bool: + """Return True if two users are friends (in either direction).""" + if user1_id is None or user2_id is None: + return False + + if user1_id == user2_id: + return True + + friendship = cls.query.filter( + ((cls.user_id == user1_id) & (cls.friend_id == user2_id)) | + ((cls.user_id == user2_id) & (cls.friend_id == user1_id)) + ).first() + return friendship is not None @classmethod def remove_friendship(cls, user1_id: int, user2_id: int)->bool: @@ -149,11 +164,7 @@ def find_friend_details(cls, user_id: int, friend_user_id: int): Return details_as_dictionary for friend_user_id if user_id and friend_user_id are friends. Returns None if not friends or user not found. """ - friendship = cls.query.filter( - ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | - ((cls.user_id == friend_user_id) & (cls.friend_id == user_id)) - ).first() - if not friendship: + if not cls.are_friends(user_id, friend_user_id): return None from zeeguu.core.model.user import User From 93fce8c895c96688704e397fcc205e86affde1d4 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 14:55:45 +0100 Subject: [PATCH 112/142] Exclude yourslef as as a friend --- zeeguu/api/endpoints/friends.py | 3 ++- zeeguu/api/test/test_friends.py | 17 +++++++++++++++-- zeeguu/core/model/friend.py | 11 +++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 5c545e0bc..a70e893b0 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -26,7 +26,8 @@ def get_friends(user_id: int = None): if used_user_id != requester_id and not Friend.are_friends(requester_id, used_user_id): return make_error(403, "You can only view friends for yourself or your friends.") - friends_with_friendships = Friend.get_friends_with_friendship(used_user_id) + exclude_id = requester_id if used_user_id != requester_id else None + friends_with_friendships = Friend.get_friends_with_friendship(used_user_id, exclude_user_id=exclude_id) result = [ _serialize_user_with_friendship(entry["user"], entry["friendship"]) for entry in friends_with_friendships diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 557f40919..7c80aa30c 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -149,7 +149,7 @@ def test_get_friends(client: LoggedInClient): def test_get_friends_for_friend_user_id(client: LoggedInClient): """ - Test /get_friends/ returns the friend's friends when users are friends. + Test /get_friends/ excludes the requester from the friend's friends list. """ other_email = "friends-list@user.com" other_client = LoggedInClient( @@ -159,18 +159,31 @@ def test_get_friends_for_friend_user_id(client: LoggedInClient): username="friends-list", learned_language="de", ) + third_email = "friends-list-third@user.com" + third_client = LoggedInClient( + client.client, + email=third_email, + password="test", + username="friends-list-third", + learned_language="de", + ) sender_user = User.find(client.email) other_user = User.find(other_email) + third_user = User.find(third_email) client.post("/send_friend_request", json={"receiver_id": other_user.id}) other_client.post("/accept_friend_request", json={"sender_id": sender_user.id}) + third_client.post("/send_friend_request", json={"receiver_id": other_user.id}) + other_client.post("/accept_friend_request", json={"sender_id": third_user.id}) + response = client.get(f"/get_friends/{other_user.id}") assert isinstance(response, list) friend_ids = [entry["id"] for entry in response] - assert sender_user.id in friend_ids + assert sender_user.id not in friend_ids + assert third_user.id in friend_ids def test_get_friends_for_non_friend_user_denied(client: LoggedInClient): diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index e6e0f4a80..751cc5bec 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -96,8 +96,11 @@ def get_friends(user_id): return friends @staticmethod - def get_friends_with_friendship(user_id: int): - """Return combined friend user + friendship data for the given user.""" + def get_friends_with_friendship(user_id: int, exclude_user_id: int = None): + """Return combined friend user + friendship data for the given user. + + exclude_user_id: if provided, that user is omitted from the results. + """ friendships : list[Friend] = Friend.query.filter( (Friend.user_id == user_id) | (Friend.friend_id == user_id) ).all() @@ -121,6 +124,10 @@ def get_friends_with_friendship(user_id: int): else: other_user_id = friendship.user_id + # Exclude if matches exclude_user_id (usually exclude_user_id is the current user, to avoid returning self as a friend) + if exclude_user_id is not None and other_user_id == exclude_user_id: + continue + friend_user = users_by_id.get(other_user_id) if not friend_user: continue From d3b227ce92a3aab32a67e81a7177fbdac03d4193 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 16:06:35 +0100 Subject: [PATCH 113/142] Working on see friend / user profile --- zeeguu/api/test/test_friends.py | 3 +++ zeeguu/core/model/friend.py | 21 ++++++++++++++------- zeeguu/core/model/user.py | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index 7c80aa30c..ad7ad553d 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -248,6 +248,9 @@ def test_get_friend_details_returns_data_for_friend(client: LoggedInClient): assert response["name"] == other_user.name assert "learned_language" in response assert "native_language" in response + assert "friends_since" in response + assert "mutual_streak" in response + assert isinstance(response["mutual_streak"], int) def test_get_friend_details_denies_non_friends(client: LoggedInClient): diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 751cc5bec..3ff325bde 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -169,17 +169,24 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: def find_friend_details(cls, user_id: int, friend_user_id: int): """ Return details_as_dictionary for friend_user_id if user_id and friend_user_id are friends. + Also includes friends_since and mutual_streak from the friendship record. Returns None if not friends or user not found. """ - if not cls.are_friends(user_id, friend_user_id): - return None - + friendship = cls.query.filter( + ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | + ((cls.user_id == friend_user_id) & (cls.friend_id == user_id)) + ).first() + from zeeguu.core.model.user import User friend = User.find_by_id(friend_user_id) - if not friend: - return None - - return friend.details_as_dictionary() + details = friend.details_as_dictionary() + if not friendship: + return details # Not friends, but return basic details without friendship info + + # When there is a friendship, enrich details with friendship info + details["friends_since"] = friendship.created_at.isoformat() if friendship.created_at else None + details["mutual_streak"] = friendship.friend_streak or 0 + return details @staticmethod diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 0c0570e82..51a265007 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -243,7 +243,7 @@ def details_as_dictionary(self): requires_email_verification=requires_email_verification, bookmark_count=bookmark_count, daily_audio_status=self.get_daily_audio_status(), - created_at=self.created_at, + created_at=self.created_at.isoformat() if self.created_at else None, user_avatar=user_avatar_dict, ) From d071bcb46ea718e0b57f7ad156da97e6665f8e7d Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 20:24:23 +0100 Subject: [PATCH 114/142] return friendship info in the get_user_details --- zeeguu/api/endpoints/user.py | 3 ++- zeeguu/api/test/test_friends.py | 35 ++++++++++++++++++++++++++++----- zeeguu/core/model/friend.py | 32 ++++++++++++++++++++---------- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index da92a894d..9b2475146 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -190,7 +190,8 @@ def get_user_details(): @requires_session def get_friend_details(friend_user_id): """ - Return user details for a friend, if the requester is actually friends with them. + Return user details for friend_user_id, including a 'friendship' object + with friend_request_status ('accepted', 'pending', or None). """ user = User.find_by_id(flask.g.user_id) from zeeguu.core.model.friend import Friend diff --git a/zeeguu/api/test/test_friends.py b/zeeguu/api/test/test_friends.py index ad7ad553d..7286c9c0c 100644 --- a/zeeguu/api/test/test_friends.py +++ b/zeeguu/api/test/test_friends.py @@ -251,18 +251,43 @@ def test_get_friend_details_returns_data_for_friend(client: LoggedInClient): assert "friends_since" in response assert "mutual_streak" in response assert isinstance(response["mutual_streak"], int) + assert response["friendship"]["friend_request_status"] == "accepted" -def test_get_friend_details_denies_non_friends(client: LoggedInClient): +def test_get_friend_details_pending_request_shows_pending_status(client: LoggedInClient): """ - Test /get_user_details/ returns an error for non-friends. + Test /get_user_details/ shows friendship.friend_request_status='pending' + when a friend request has been sent but not yet accepted. """ - stranger_email = "not-a-friend@user.com" + pending_email = "pending-details@user.com" + LoggedInClient( + client.client, + email=pending_email, + password="test", + username="pending-details", + learned_language="de", + ) + pending_user = User.find(pending_email) + + client.post("/send_friend_request", json={"receiver_id": pending_user.id}) + + response = client.get(f"/get_user_details/{pending_user.id}") + + assert isinstance(response, dict) + assert response["friendship"]["friend_request_status"] == "pending" + + +def test_get_friend_details_no_relationship_returns_none_friendship(client: LoggedInClient): + """ + Test /get_user_details/ returns friendship=None when there is + no friendship or friend request between the users. + """ + stranger_email = "no-relation@user.com" LoggedInClient( client.client, email=stranger_email, password="test", - username="not-a-friend", + username="no-relation", learned_language="de", ) stranger_user = User.find(stranger_email) @@ -270,5 +295,5 @@ def test_get_friend_details_denies_non_friends(client: LoggedInClient): response = client.get(f"/get_user_details/{stranger_user.id}") assert isinstance(response, dict) - assert response.get("error") == "Not friends with this user or user not found." + assert response.get("friendship") is None diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index 3ff325bde..d0d3edc43 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -168,24 +168,36 @@ def remove_friendship(cls, user1_id: int, user2_id: int)->bool: @classmethod def find_friend_details(cls, user_id: int, friend_user_id: int): """ - Return details_as_dictionary for friend_user_id if user_id and friend_user_id are friends. - Also includes friends_since and mutual_streak from the friendship record. - Returns None if not friends or user not found. + Return details_as_dictionary for friend_user_id. + Always includes a 'friendship' object with friend_request_status + ('accepted', 'pending', 'rejected', or None if no relationship). + When friends, also includes friends_since and mutual_streak. + Returns None if the target user is not found. """ + from zeeguu.core.model.user import User + from zeeguu.core.model.friend_request import FriendRequest + + friend = User.find_by_id(friend_user_id) + if not friend: + return None + friendship = cls.query.filter( ((cls.user_id == user_id) & (cls.friend_id == friend_user_id)) | ((cls.user_id == friend_user_id) & (cls.friend_id == user_id)) ).first() - from zeeguu.core.model.user import User - friend = User.find_by_id(friend_user_id) + friend_request = FriendRequest.query.filter( + ((FriendRequest.sender_id == user_id) & (FriendRequest.receiver_id == friend_user_id)) | + ((FriendRequest.sender_id == friend_user_id) & (FriendRequest.receiver_id == user_id)) + ).order_by(FriendRequest.created_at.desc()).first() + details = friend.details_as_dictionary() - if not friendship: - return details # Not friends, but return basic details without friendship info + details["friendship"] = cls._get_friendship_or_friendrequest(friendship, friend_request) + + if friendship: + details["friends_since"] = friendship.created_at.isoformat() if friendship.created_at else None + details["mutual_streak"] = friendship.friend_streak or 0 - # When there is a friendship, enrich details with friendship info - details["friends_since"] = friendship.created_at.isoformat() if friendship.created_at else None - details["mutual_streak"] = friendship.friend_streak or 0 return details From 3d17d29e35a4e584d2d064c80af2e5416a2bf8ae Mon Sep 17 00:00:00 2001 From: gabortodor Date: Sat, 14 Mar 2026 12:12:01 +0100 Subject: [PATCH 115/142] Added avatar saving for users --- zeeguu/api/endpoints/user.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 9b2475146..2a41c8c51 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -247,6 +247,34 @@ def user_settings(): submitted_avatar_background_color) zeeguu.core.model.db.session.add(user_avatar) + 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) + + if any([ + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color + ]): + user_avatar = UserAvatar.find(user_id) + + if not user_avatar: + user_avatar = UserAvatar(user_id, + submitted_avatar_image_name, + submitted_avatar_character_color, + submitted_avatar_background_color) + else: + if submitted_avatar_image_name: + user_avatar.image_name = submitted_avatar_image_name + + if submitted_avatar_character_color: + user_avatar.character_color = submitted_avatar_character_color + + if submitted_avatar_background_color: + user_avatar.background_color = submitted_avatar_background_color + + zeeguu.core.model.db.session.add(user_avatar) + zeeguu.core.model.db.session.add(user) zeeguu.core.model.db.session.commit() return "OK" From 307d8cdaaee5da2274bf0af31015cdc6e0ab7f57 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 14:48:34 +0100 Subject: [PATCH 116/142] Allow setting password via /user_settings endpoint Supports the new upgrade flow where anonymous users set their password after email confirmation, rather than all at once. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/user.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 2a41c8c51..43a4fa89b 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -275,6 +275,10 @@ def user_settings(): 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" From 6bd99fdb22209a02d3dbf1e2ca3e9736a050ad19 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:22:37 +0100 Subject: [PATCH 117/142] Add atomic account upgrade to avoid email verification limbo state New endpoints: request_email_verification (sends code without changing user) and complete_account_upgrade (verifies code + upgrades atomically with email_verified=True). User stays anonymous until fully verified. Also add @allows_unverified to user_settings and user_preferences as a safety net for the old upgrade flow. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/user.py | 1 + zeeguu/api/endpoints/user_preferences.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 43a4fa89b..d38743a68 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -204,6 +204,7 @@ def get_friend_details(friend_user_id): @api.route("/user_settings", methods=["POST"]) @cross_domain @requires_session +@allows_unverified def user_settings(): """ :return: OK for success diff --git a/zeeguu/api/endpoints/user_preferences.py b/zeeguu/api/endpoints/user_preferences.py index 9dd95f550..79a1d5b31 100644 --- a/zeeguu/api/endpoints/user_preferences.py +++ b/zeeguu/api/endpoints/user_preferences.py @@ -2,7 +2,7 @@ import zeeguu.core from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified from . import api from ...core.model import UserPreference, User @@ -12,6 +12,7 @@ @api.route("/user_preferences", methods=["GET"]) @cross_domain @requires_session +@allows_unverified def user_preferences(): user = User.find_by_id(flask.g.user_id) return json_result(UserPreference.all_for_user(user)) From ab089dbad5865b47bbb31bebead3de80fc9c0df2 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:25:56 +0100 Subject: [PATCH 118/142] Add @allows_unverified to daily_streak endpoint Safety net for users stuck in the old upgrade limbo state. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/daily_streak.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index a2ebfa11a..928e97bfa 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -1,7 +1,7 @@ import flask from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified from . import api from ...core.model import User from ...core.model.user_language import UserLanguage @@ -11,6 +11,7 @@ @api.route("/daily_streak", methods=["GET"]) @cross_domain @requires_session +@allows_unverified def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) From 8526f2fa9e311e44f223014a91649a34e484fc01 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 13 Mar 2026 16:28:06 +0100 Subject: [PATCH 119/142] Revert @allows_unverified from daily_streak, user_settings, user_preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed — the new atomic upgrade flow keeps users anonymous until fully verified, so they never hit the unverified limbo state. Co-Authored-By: Claude Opus 4.6 --- zeeguu/api/endpoints/daily_streak.py | 3 +-- zeeguu/api/endpoints/user.py | 1 - zeeguu/api/endpoints/user_preferences.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index 928e97bfa..a2ebfa11a 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -1,7 +1,7 @@ import flask from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api from ...core.model import User from ...core.model.user_language import UserLanguage @@ -11,7 +11,6 @@ @api.route("/daily_streak", methods=["GET"]) @cross_domain @requires_session -@allows_unverified def get_daily_streak(): user = User.find_by_id(flask.g.user_id) user_language = UserLanguage.find_or_create(db.session, user, user.learned_language) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index d38743a68..43a4fa89b 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -204,7 +204,6 @@ def get_friend_details(friend_user_id): @api.route("/user_settings", methods=["POST"]) @cross_domain @requires_session -@allows_unverified def user_settings(): """ :return: OK for success diff --git a/zeeguu/api/endpoints/user_preferences.py b/zeeguu/api/endpoints/user_preferences.py index 79a1d5b31..9dd95f550 100644 --- a/zeeguu/api/endpoints/user_preferences.py +++ b/zeeguu/api/endpoints/user_preferences.py @@ -2,7 +2,7 @@ import zeeguu.core from zeeguu.api.utils.json_result import json_result -from zeeguu.api.utils.route_wrappers import cross_domain, requires_session, allows_unverified +from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api from ...core.model import UserPreference, User @@ -12,7 +12,6 @@ @api.route("/user_preferences", methods=["GET"]) @cross_domain @requires_session -@allows_unverified def user_preferences(): user = User.find_by_id(flask.g.user_id) return json_result(UserPreference.all_for_user(user)) From dd79075b567c17e8a05fb7b128b134167df64ce8 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Sat, 14 Mar 2026 17:18:14 +0100 Subject: [PATCH 120/142] User avatar saving refactor --- zeeguu/api/endpoints/user.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/zeeguu/api/endpoints/user.py b/zeeguu/api/endpoints/user.py index 43a4fa89b..da9f1ef27 100644 --- a/zeeguu/api/endpoints/user.py +++ b/zeeguu/api/endpoints/user.py @@ -245,12 +245,6 @@ def user_settings(): 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) - - zeeguu.core.model.db.session.add(user_avatar) - 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) - if any([ submitted_avatar_image_name, submitted_avatar_character_color, From 854d5cc8852253c5e2e35cb3ce5f8dbb817587dd Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:06:45 +0100 Subject: [PATCH 121/142] Update friend streak and db session logic --- zeeguu/core/model/friend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index d0d3edc43..a93e5ad6a 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ 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 datetime import datetime, timedelta, UTC class Friend(db.Model): __tablename__ = "friends" @@ -67,6 +67,10 @@ def update_friend_streak(self, session=None, commit=True): session.add(self) if commit: session.commit() + if session: + session.add(self) + if commit: + session.commit() # Explicit relationships with primaryjoin user = relationship( From 685a76293f9f7e3762e87928eb812287a9197cca Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 17 Mar 2026 10:23:16 +0100 Subject: [PATCH 122/142] fix tests --- zeeguu/core/model/friend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeeguu/core/model/friend.py b/zeeguu/core/model/friend.py index a93e5ad6a..528d19fae 100644 --- a/zeeguu/core/model/friend.py +++ b/zeeguu/core/model/friend.py @@ -2,7 +2,7 @@ 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, UTC +from datetime import datetime, timedelta class Friend(db.Model): __tablename__ = "friends" From e60323b94647a507299bf3a0df25f32ea8cf219f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 18 Mar 2026 11:01:14 +0100 Subject: [PATCH 123/142] Working on friendship and language --- zeeguu/api/endpoints/friends.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index a70e893b0..7f128257d 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,6 +1,6 @@ import flask from flask import request -from zeeguu.core.model import User +from zeeguu.core.model import User, user from zeeguu.core.model.friend import Friend from zeeguu.core.model.friend_request import FriendRequest from zeeguu.api.utils.json_result import json_result @@ -37,7 +37,13 @@ def get_friends(user_id: int = None): def _serialize_user_with_friendship(user, friendship): user_data = _serialize_user(user) - user_data["friendship"] = _serialize_friendship(friendship) + 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 return user_data # --------------------------------------------------------------------------- @@ -257,12 +263,17 @@ def _serialize_friendship(friendship: Friend, status: str = "accepted"): } def _serialize_user(user: User): - return { - "id": user.id, - "name": user.name, - "username": user.username, - "email": user.email, - } + if user is None: + warning("_serialize_user: user is None") + return {} + + result = user.details_as_dictionary() or {} + if not isinstance(result, dict): + warning(f"_serialize_user: details_as_dictionary returned {type(result)} for user_id={user.id}") + result = {} + + result["id"] = user.id + return result def _serialize_users(users: list[User]): return [_serialize_user(user) for user in users] From 8b054cf07dc45947ad8d5796a3f8d97655dd1608 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Wed, 18 Mar 2026 20:15:45 +0100 Subject: [PATCH 124/142] the user language info --- zeeguu/api/endpoints/friends.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index 7f128257d..fd8141fd2 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -7,6 +7,7 @@ from sqlalchemy.orm.exc import NoResultFound from zeeguu.api.utils.abort_handling import make_error from zeeguu.api.utils.route_wrappers import cross_domain, requires_session +from zeeguu.core.model.user_language import UserLanguage from zeeguu.logging import log, debug, warning, critical from . import api @@ -35,7 +36,7 @@ def get_friends(user_id: int = None): log(f"get_friends: requester_id={requester_id} requested friends for user_id={used_user_id}; count={len(result)}") return json_result(result) -def _serialize_user_with_friendship(user, friendship): +def _serialize_user_with_friendship(user: User, friendship): user_data = _serialize_user(user) if not isinstance(user_data, dict): warning( @@ -44,6 +45,7 @@ def _serialize_user_with_friendship(user, friendship): 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 # --------------------------------------------------------------------------- @@ -267,14 +269,22 @@ def _serialize_user(user: User): warning("_serialize_user: user is None") return {} + result = user.details_as_dictionary() or {} if not isinstance(result, dict): warning(f"_serialize_user: details_as_dictionary returned {type(result)} for user_id={user.id}") result = {} result["id"] = user.id + return result +def _serialize_user_languages(user): + # Add all languages the user is learning + from zeeguu.core.model.user_language import UserLanguage + user_languages = UserLanguage.all_user_languages_for_user(user) + return [ul.language.as_dictionary() for ul in user_languages] + def _serialize_users(users: list[User]): return [_serialize_user(user) for user in users] From 12123d4f51853d23ae9eb4f829c2432ee2d4aec4 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 19 Mar 2026 10:15:49 +0100 Subject: [PATCH 125/142] Modified get_badges_for_user return value --- zeeguu/api/endpoints/badges.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/zeeguu/api/endpoints/badges.py b/zeeguu/api/endpoints/badges.py index 1b4b7d60d..09ba6b95a 100644 --- a/zeeguu/api/endpoints/badges.py +++ b/zeeguu/api/endpoints/badges.py @@ -39,7 +39,6 @@ def get_badges_for_user(user_id: int = None): Returns: [ { - "badge_id": 1, "name": "Meaning Builder", "description": "Translate {target_value} words while reading.", "levels": [ @@ -103,7 +102,6 @@ def serialize_badge(badge: Badge, achieved_map: dict, progress_map: dict) -> dic ] return { - "badge_id": badge.id, "name": badge.name, "description": badge.description, "levels": levels, From 8eb6cdf516567d1a0ef67bd37b9c34e6479698b4 Mon Sep 17 00:00:00 2001 From: kalnyzalan Date: Thu, 19 Mar 2026 16:19:22 +0100 Subject: [PATCH 126/142] Extended get_all_daily_streak for friend profiles as well --- zeeguu/api/endpoints/daily_streak.py | 20 ++++++++++++++------ zeeguu/api/endpoints/friends.py | 15 ++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/zeeguu/api/endpoints/daily_streak.py b/zeeguu/api/endpoints/daily_streak.py index a2ebfa11a..c29d97630 100644 --- a/zeeguu/api/endpoints/daily_streak.py +++ b/zeeguu/api/endpoints/daily_streak.py @@ -1,5 +1,6 @@ import flask +from zeeguu.core.model.friend import Friend from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from . import api @@ -22,19 +23,26 @@ def get_daily_streak(): @api.route("/all_daily_streak", methods=["GET"]) +@api.route("/all_daily_streak/", methods=["GET"]) @cross_domain @requires_session -def get_all_daily_streak(): - user = User.find_by_id(flask.g.user_id) +def get_all_daily_streak(user_id: int = None): + requester_user_id = flask.g.user_id + requested_user_id = user_id if user_id is not None else requester_user_id + + user = User.find_by_id(requested_user_id) user_languages = UserLanguage.all_user_languages_for_user(user) result = [] for user_language in user_languages: obj = { "language": user_language.language.as_dictionary(), - "daily_streak": user_language.daily_streak or 0, - "max_streak": user_language.max_streak or 0, - "max_streak_date": user_language.max_streak_date.strftime( - "%Y-%m-%d") if user_language.max_streak_date else None } + if requester_user_id == requested_user_id or Friend.are_friends(requester_user_id, requested_user_id): + obj.update({ + "daily_streak": user_language.daily_streak or 0, + "max_streak": user_language.max_streak or 0, + "max_streak_date": user_language.max_streak_date.strftime( + "%Y-%m-%d") if user_language.max_streak_date else None + }) result.append(obj) return json_result(result) diff --git a/zeeguu/api/endpoints/friends.py b/zeeguu/api/endpoints/friends.py index fd8141fd2..cba5020fe 100644 --- a/zeeguu/api/endpoints/friends.py +++ b/zeeguu/api/endpoints/friends.py @@ -1,16 +1,17 @@ import flask from flask import request -from zeeguu.core.model import User, user -from zeeguu.core.model.friend import Friend -from zeeguu.core.model.friend_request import FriendRequest -from zeeguu.api.utils.json_result import json_result from sqlalchemy.orm.exc import NoResultFound + from zeeguu.api.utils.abort_handling import make_error +from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session -from zeeguu.core.model.user_language import UserLanguage -from zeeguu.logging import log, debug, warning, critical +from zeeguu.core.model import User +from zeeguu.core.model.friend import Friend +from zeeguu.core.model.friend_request import FriendRequest +from zeeguu.logging import log, warning from . import api + # --------------------------------------------------------------------------- @api.route("/get_friends", methods=["GET"]) @api.route("/get_friends/", methods=["GET"]) @@ -280,7 +281,7 @@ def _serialize_user(user: User): return result def _serialize_user_languages(user): - # Add all languages the user is learning + # 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] From 156b1f700f0fde5758d483d284fc7215494a0b16 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:31:41 +0100 Subject: [PATCH 127/142] Now with gamification feature flag --- zeeguu/core/user_feature_toggles.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 13762a596..e7a447262 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -16,6 +16,7 @@ def _feature_map(): "new_topics": _new_topics, "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, + "gamification": _gamification, } @@ -78,8 +79,18 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - + # ...existing code... for user_cohort in user.cohorts: if user_cohort.cohort_id in COHORTS_WITH_HIDDEN_RECOMMENDATIONS: return True return False + +# Gamification feature flag logic +from model.user import User +def _gamification(user: User): + """ + Enable gamification features for users whose invitation code is exactly 'gamification'. + """ + GAMIFICATION_INVITE_CODE = "gamification" + return user.invitation_code == GAMIFICATION_INVITE_CODE + From 18258066e17ffeacac772c98ed6b0e3e13d6a500 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:44:19 +0100 Subject: [PATCH 128/142] Working on the gamification feature flag --- zeeguu/core/user_feature_toggles.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index e7a447262..ddd8d39c8 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -86,11 +86,18 @@ def _hide_recommendations(user): return False # Gamification feature flag logic -from model.user import User +from .model.user import User def _gamification(user: User): """ Enable gamification features for users whose invitation code is exactly 'gamification'. """ - GAMIFICATION_INVITE_CODE = "gamification" + from datetime import datetime, date + GAMIFICATION_INVITE_CODE = "gamification" # I guess we can decide on the invitation code + GAMIFICATION_START_DATE = date(2026, 4, 1) # Start after the first of April 2026 + + # Start gamification features after the GAMIFICATION_START_DATE + if datetime.now().date() > GAMIFICATION_START_DATE: + return False + return user.invitation_code == GAMIFICATION_INVITE_CODE From 9ee3ff0a8d4b816b200c68fdb75173311d00da79 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:45:07 +0100 Subject: [PATCH 129/142] Deleted comment --- zeeguu/core/user_feature_toggles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index ddd8d39c8..7632239a8 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -79,7 +79,6 @@ def _hide_recommendations(user): return False COHORTS_WITH_HIDDEN_RECOMMENDATIONS = {564} - # ...existing code... for user_cohort in user.cohorts: if user_cohort.cohort_id in COHORTS_WITH_HIDDEN_RECOMMENDATIONS: return True From 7c1794886e9717d1178515aee932ecafc3024e72 Mon Sep 17 00:00:00 2001 From: nicra Date: Wed, 25 Mar 2026 10:58:10 +0100 Subject: [PATCH 130/142] working on feature flags for all gamification features --- zeeguu/core/user_feature_toggles.py | 42 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 7632239a8..3f09f3805 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -17,6 +17,9 @@ def _feature_map(): "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, "gamification": _gamification, + "badges": _badges, + "friends": _friends, + "leaderboards": _leaderboards, } @@ -86,17 +89,40 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User + def _gamification(user: User): """ - Enable gamification features for users whose invitation code is exactly 'gamification'. + Enable general gamification features for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _badges(user: User): + """ + Enable badges feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _friends(user: User): + """ + Enable friends feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _leaderboards(user: User): + """ + Enable leaderboards feature for users whose invitation code is exactly 'gamification'. + """ + return _gamification_flag_logic(user) + +def _gamification_flag_logic(user: User): + """ + Shared logic for enabling gamification-related features. """ - from datetime import datetime, date - GAMIFICATION_INVITE_CODE = "gamification" # I guess we can decide on the invitation code - GAMIFICATION_START_DATE = date(2026, 4, 1) # Start after the first of April 2026 - - # Start gamification features after the GAMIFICATION_START_DATE + from datetime import datetime, date + GAMIFICATION_INVITE_CODE = "gamification" + GAMIFICATION_START_DATE = date(2026, 4, 1) + # Only enable before the start date if datetime.now().date() > GAMIFICATION_START_DATE: return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + return getattr(user, "invitation_code", None) == GAMIFICATION_INVITE_CODE From 7544574e0e64da3931ad64a2915f4a53cde1c3e6 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Mar 2026 09:16:47 +0100 Subject: [PATCH 131/142] Invite code for all features --- zeeguu/core/user_feature_toggles.py | 63 ++++++++++++++++++----------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 3f09f3805..321216aa6 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -89,7 +89,8 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User - +from datetime import datetime, date +GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ Enable general gamification features for users whose invitation code is exactly 'gamification'. @@ -97,32 +98,46 @@ def _gamification(user: User): return _gamification_flag_logic(user) def _badges(user: User): - """ - Enable badges feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable badges feature for users whose invitation code is exactly 'gamification'. + """ + BADGES_INVITE_CODE = "badges_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == BADGES_INVITE_CODE def _friends(user: User): - """ - Enable friends feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable friends feature for users whose invitation code is exactly 'gamification'. + """ + FRIENDS_INVITE_CODE = "friends_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == FRIENDS_INVITE_CODE def _leaderboards(user: User): - """ - Enable leaderboards feature for users whose invitation code is exactly 'gamification'. - """ - return _gamification_flag_logic(user) + """ + Enable leaderboards feature for users whose invitation code is exactly 'gamification'. + """ + LEADERBOARDS_INVITE_CODE = "leaderboards_invite_code" + if not _has_gamification_started(): + return False + + return user.invitation_code == LEADERBOARDS_INVITE_CODE def _gamification_flag_logic(user: User): - """ - Shared logic for enabling gamification-related features. - """ - from datetime import datetime, date - GAMIFICATION_INVITE_CODE = "gamification" - GAMIFICATION_START_DATE = date(2026, 4, 1) - # Only enable before the start date - if datetime.now().date() > GAMIFICATION_START_DATE: - return False - return getattr(user, "invitation_code", None) == GAMIFICATION_INVITE_CODE - + """ + Shared logic for enabling gamification-related features. + """ + + GAMIFICATION_INVITE_CODE = "gamification" + # Only enable before the start date + if not _has_gamification_started(): + return False + + return user.invitation_code == GAMIFICATION_INVITE_CODE + +def _has_gamification_started(): + return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file From b0b2470cea75466af1590d362dbf8f76e15b85e4 Mon Sep 17 00:00:00 2001 From: nicra Date: Thu, 26 Mar 2026 14:27:37 +0100 Subject: [PATCH 132/142] Is dev for the gamification feature --- zeeguu/core/user_feature_toggles.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 321216aa6..6597586e9 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -133,7 +133,10 @@ def _gamification_flag_logic(user: User): """ GAMIFICATION_INVITE_CODE = "gamification" - # Only enable before the start date + if user.is_dev: + return True + + # Only enable after the start date if not _has_gamification_started(): return False From 3d25288c3bd6edc8d7215f631cbfa8d577712195 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Tue, 31 Mar 2026 13:27:27 +0200 Subject: [PATCH 133/142] clean up logic for gamification --- zeeguu/core/user_feature_toggles.py | 65 +++++++---------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 6597586e9..32aecaed2 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -16,10 +16,7 @@ def _feature_map(): "new_topics": _new_topics, "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, - "gamification": _gamification, - "badges": _badges, - "friends": _friends, - "leaderboards": _leaderboards, + "gamification": _gamification } @@ -90,57 +87,23 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User from datetime import datetime, date + +from zeeguu.core.model import user GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ Enable general gamification features for users whose invitation code is exactly 'gamification'. """ - return _gamification_flag_logic(user) - -def _badges(user: User): - """ - Enable badges feature for users whose invitation code is exactly 'gamification'. - """ - BADGES_INVITE_CODE = "badges_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == BADGES_INVITE_CODE - -def _friends(user: User): - """ - Enable friends feature for users whose invitation code is exactly 'gamification'. - """ - FRIENDS_INVITE_CODE = "friends_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == FRIENDS_INVITE_CODE - -def _leaderboards(user: User): - """ - Enable leaderboards feature for users whose invitation code is exactly 'gamification'. - """ - LEADERBOARDS_INVITE_CODE = "leaderboards_invite_code" - if not _has_gamification_started(): - return False - - return user.invitation_code == LEADERBOARDS_INVITE_CODE - -def _gamification_flag_logic(user: User): - """ - Shared logic for enabling gamification-related features. - """ - - GAMIFICATION_INVITE_CODE = "gamification" - if user.is_dev: - return True - - # Only enable after the start date - if not _has_gamification_started(): - return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + + GAMIFICATION_INVITE_CODE = "gamification" + if user.is_dev: + return True + + # Only enable after the start date + if not _has_gamification_started(): + return False + + return user.invitation_code == GAMIFICATION_INVITE_CODE def _has_gamification_started(): - return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file + return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file From 094b345996e79067f0cb99f8bad9a406db44df2f Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 13:48:55 +0200 Subject: [PATCH 134/142] update the gamification flag logic --- zeeguu/core/user_feature_toggles.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 32aecaed2..e05bf52ad 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -86,24 +86,26 @@ def _hide_recommendations(user): # Gamification feature flag logic from .model.user import User +from .model.cohort import Cohort from datetime import datetime, date - -from zeeguu.core.model import user GAMIFICATION_START_DATE = date(2026, 4, 1) def _gamification(user: User): """ - Enable general gamification features for users whose invitation code is exactly 'gamification'. + Enable general gamification features for users whose invitation with the gamification invite code, + or who are in the gamification cohort. This includes features like badges, friends, and leaderboards. """ - GAMIFICATION_INVITE_CODE = "gamification" + GAMIFICATION_INVITE_CODE = "CD8HGKKJ" if user.is_dev: return True - # Only enable after the start date - if not _has_gamification_started(): - return False - - return user.invitation_code == GAMIFICATION_INVITE_CODE + if user.invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + return True + + # Find gamification cohort by invite code + gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + if gamification_cohort and user.is_member_of_cohort(gamification_cohort.id): + return True -def _has_gamification_started(): - return datetime.now().date() >= GAMIFICATION_START_DATE \ No newline at end of file + # Disabled for everyone else + return False From f284eb04553611e85791777152c5cde9669588c8 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Tue, 24 Mar 2026 17:24:41 +0100 Subject: [PATCH 135/142] Add upgrade_to_teacher tool script Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/upgrade_to_teacher.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tools/upgrade_to_teacher.py diff --git a/tools/upgrade_to_teacher.py b/tools/upgrade_to_teacher.py new file mode 100644 index 000000000..a01b0fb0e --- /dev/null +++ b/tools/upgrade_to_teacher.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +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.teacher import Teacher + +email = input("User email: ") + +user = User.find(email) +if not user: + print(f"No user found with email: {email}") + exit(1) + +print(f"Found user: {user.name} (id={user.id})") + +if Teacher.exists(user): + print("User is already a teacher.") + exit(0) + +teacher = Teacher(user) +db.session.add(teacher) +db.session.commit() + +print(f"User '{user.name}' has been upgraded to teacher.") From 0e52cdde1741d59ef547988387671ea7ad6ec6b5 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Thu, 26 Mar 2026 12:18:58 +0100 Subject: [PATCH 136/142] Fix article extraction to use readability-cleaned HTML Article.find_or_create() was using np_article.html (raw newspaper HTML) instead of np_article.htmlContent (readability server output). This caused navigation menus, headers, and footers to appear as bullet-point lists in shared articles. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/core/model/article.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/model/article.py b/zeeguu/core/model/article.py index ab8cf9582..1cff0bf80 100644 --- a/zeeguu/core/model/article.py +++ b/zeeguu/core/model/article.py @@ -1350,8 +1350,8 @@ def find_or_create( canonical_url, html_content=html_content ) - # newspaper Article objects use .html, not .htmlContent - html_content = np_article.html + # Use readability-cleaned HTML (not raw np_article.html) + html_content = np_article.htmlContent article_text = np_article.text # Full article text from readability server title = np_article.title authors = ", ".join(np_article.authors or []) From 563467386bdfc08586e6bebe86e404e019345491 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 27 Mar 2026 17:04:53 +0100 Subject: [PATCH 137/142] Fix Anthropic JSON parsing: accept literal newlines from LLM The LLM returns JSON with literal newlines inside string values (instead of escaped \n), which json.loads rejects in strict mode. This caused fallback to DeepSeek (~90s vs ~3s for Anthropic). Using json.loads(strict=False) accepts these control characters. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/core/llm_services/simplification_service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/llm_services/simplification_service.py b/zeeguu/core/llm_services/simplification_service.py index c4fbd4091..63b77e56c 100644 --- a/zeeguu/core/llm_services/simplification_service.py +++ b/zeeguu/core/llm_services/simplification_service.py @@ -575,7 +575,9 @@ def clean_text(text): import json import markdown2 try: - result = json.loads(result_text) + # LLMs often return JSON with literal newlines inside + # string values (instead of \n), which strict JSON rejects + result = json.loads(result_text, strict=False) # Convert markdown content to HTML if "content" in result and result["content"]: From fa60ee518b2cf49c658346fe33554442d8f0aadb Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 27 Mar 2026 17:12:11 +0100 Subject: [PATCH 138/142] Support withContent=false in find_or_create_article Skip expensive tokenization when caller only needs article metadata (id, language, title). Used by SharedArticleHandler to show the language choice modal immediately without waiting for NLP processing. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/api/endpoints/article.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/article.py b/zeeguu/api/endpoints/article.py index 43eed856c..23f1bab2a 100644 --- a/zeeguu/api/endpoints/article.py +++ b/zeeguu/api/endpoints/article.py @@ -39,6 +39,7 @@ def find_or_create_article(): title = request.form.get("title", "") if pre_extracted else None author = request.form.get("author", "") if pre_extracted else None image_url = request.form.get("imageUrl", "") if pre_extracted else None + with_content = request.form.get("withContent", "true") == "true" print("-- url: " + url) print("-- pre_extracted: " + str(pre_extracted)) @@ -67,7 +68,7 @@ def find_or_create_article(): article.assess_cefr_level(db_session) print("-- article CEFR level assessed") - uai = UserArticle.user_article_info(user, article, with_content=True) + uai = UserArticle.user_article_info(user, article, with_content=with_content) print("-- returning user article info: ", json.dumps(uai)[:50]) return json_result(uai) except NoResultFound as e: From fd67d5843b52f8bc4993a19600bc89fe8edff5f2 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 27 Mar 2026 17:30:13 +0100 Subject: [PATCH 139/142] Add lightweight /detect_article_info endpoint for share modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads and detects language + title without creating the article in the DB. Returns instantly for already-existing articles. Reverts the withContent flag — no longer needed since the share flow now uses this lightweight endpoint instead of find_or_create_article. Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/api/endpoints/article.py | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/zeeguu/api/endpoints/article.py b/zeeguu/api/endpoints/article.py index 23f1bab2a..915550f32 100644 --- a/zeeguu/api/endpoints/article.py +++ b/zeeguu/api/endpoints/article.py @@ -39,7 +39,6 @@ def find_or_create_article(): title = request.form.get("title", "") if pre_extracted else None author = request.form.get("author", "") if pre_extracted else None image_url = request.form.get("imageUrl", "") if pre_extracted else None - with_content = request.form.get("withContent", "true") == "true" print("-- url: " + url) print("-- pre_extracted: " + str(pre_extracted)) @@ -63,12 +62,11 @@ def find_or_create_article(): print("-- article found or created: " + str(article.id)) # Assess CEFR level for user-initiated article reading - # (only assess if article doesn't already have an assessment) if not article.cefr_assessment or not article.cefr_assessment.llm_cefr_level: article.assess_cefr_level(db_session) print("-- article CEFR level assessed") - uai = UserArticle.user_article_info(user, article, with_content=with_content) + uai = UserArticle.user_article_info(user, article, with_content=True) print("-- returning user article info: ", json.dumps(uai)[:50]) return json_result(uai) except NoResultFound as e: @@ -92,6 +90,58 @@ def find_or_create_article(): flask.abort(500) +# --------------------------------------------------------------------------- +@api.route("/detect_article_info", methods=("POST",)) +# --------------------------------------------------------------------------- +@cross_domain +@requires_session +def detect_article_info(): + """ + Lightweight endpoint: downloads a URL, detects language and title. + Does NOT create an article in the DB. + Used by the share flow to show the language choice modal fast. + + Expects: url (str) + Returns: {language, title, url} + """ + url = request.form.get("url", "") + if not url: + flask.abort(400, "URL required") + + from zeeguu.core.model.url import Url + + canonical_url = Url.extract_canonical_url(url) + + # Check if article already exists — if so, return instantly + existing = Article.find(canonical_url) + if existing: + return json_result({ + "id": existing.id, + "language": existing.language.code, + "title": existing.title, + "url": canonical_url, + "exists": True, + }) + + # Download and detect language without creating article + try: + from zeeguu.core.content_retriever import readability_download_and_parse + + np_article = readability_download_and_parse(canonical_url) + lang = np_article.meta_lang + title = np_article.title + + return json_result({ + "language": lang, + "title": title, + "url": canonical_url, + "exists": False, + }) + except Exception as e: + log(f"detect_article_info failed for {url}: {e}") + flask.abort(422, "Could not parse article") + + # --------------------------------------------------------------------------- @api.route("/make_personal_copy", methods=("POST",)) # --------------------------------------------------------------------------- From ab24d8b53dc9c90308e78c1c085b1340c09804d1 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Sun, 29 Mar 2026 14:43:44 +0200 Subject: [PATCH 140/142] Show non-simplified articles only for legacy users via feature toggle Co-Authored-By: Claude Opus 4.6 (1M context) --- zeeguu/core/model/user_article.py | 4 +++- zeeguu/core/user_feature_toggles.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/zeeguu/core/model/user_article.py b/zeeguu/core/model/user_article.py index 6ab1fb5be..122f441af 100644 --- a/zeeguu/core/model/user_article.py +++ b/zeeguu/core/model/user_article.py @@ -654,8 +654,10 @@ def article_infos(cls, user, articles, select_appropriate=True): # Don't show original articles that aren't simplified — # they'd open externally, which defeats the purpose + # (legacy users with the feature toggle can still see them) if not article.parent_article_id and not article.uploader_id: - continue + if not user.has_feature("show_non_simplified_articles"): + continue if article.id in seen_ids: continue diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index e05bf52ad..a0bdb39da 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -16,6 +16,7 @@ def _feature_map(): "new_topics": _new_topics, "daily_feedback": _daily_feedback, "hide_recommendations": _hide_recommendations, + "show_non_simplified_articles": _show_non_simplified_articles, "gamification": _gamification } @@ -69,6 +70,17 @@ def _extension_experiment_1(user): ) +def _show_non_simplified_articles(user): + """Show non-simplified (original) articles for legacy users. + + Most users only see simplified articles. These legacy users + were active before simplification was standard and still expect + to see original articles in their feed. + """ + LEGACY_USER_IDS = {4607, 4626} + return user.id in LEGACY_USER_IDS + + def _hide_recommendations(user): """Hide recommended articles for students in specific cohorts. From c2210511983e4c7647a0ebc1f3da833682411a99 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 14:30:20 +0200 Subject: [PATCH 141/142] fix invitation code check --- zeeguu/core/user_feature_toggles.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index a0bdb39da..0ca00d61d 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -110,8 +110,10 @@ def _gamification(user: User): GAMIFICATION_INVITE_CODE = "CD8HGKKJ" if user.is_dev: return True - - if user.invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): + + # Invitation code can be None + invitation_code = user.invitation_code or "" + if invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): return True # Find gamification cohort by invite code From 871fcabe48634c5765915b30657bce375881b305 Mon Sep 17 00:00:00 2001 From: xXPinkmagicXx Date: Thu, 2 Apr 2026 16:24:18 +0200 Subject: [PATCH 142/142] Try except logic for gamification cohort --- zeeguu/core/user_feature_toggles.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/zeeguu/core/user_feature_toggles.py b/zeeguu/core/user_feature_toggles.py index 0ca00d61d..fd5e04e4d 100644 --- a/zeeguu/core/user_feature_toggles.py +++ b/zeeguu/core/user_feature_toggles.py @@ -97,6 +97,8 @@ def _hide_recommendations(user): return False # Gamification feature flag logic +from sqlalchemy.exc import NoResultFound + from .model.user import User from .model.cohort import Cohort from datetime import datetime, date @@ -116,8 +118,12 @@ def _gamification(user: User): if invitation_code.lower() == GAMIFICATION_INVITE_CODE.lower(): return True - # Find gamification cohort by invite code - gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + # Find gamification cohort by invite code, if it exists. + try: + gamification_cohort = Cohort.find_by_code(GAMIFICATION_INVITE_CODE) + except NoResultFound: + gamification_cohort = None + if gamification_cohort and user.is_member_of_cohort(gamification_cohort.id): return True