From 10caa82320f6333b134c18e6fbda597fcf93ca9f Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 6 Oct 2025 20:57:17 +0800 Subject: [PATCH] feat(user-preference): add user preference support (#55) APIs: - GET `/api/private/user/preferences`: Get current user's preferences. - PATCH `/api/private/user/preferences`: Modify current user's preferences. (body: Preferences) - PUT `/api/private/user/preferences`: Overwrite current user's preferences. (body: Preferences) - DELETE `/api/private/user/preferences`: Reset current user's preferences. (body: list[str]) - body specifies the content to be reset. If body is empty, reset all preferences. User: - `User.g0v0_playmode`: show the special ruleset like `OSURX`, and custom rulesets in the future. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/database/__init__.py | 2 + app/database/user.py | 33 ++- app/database/user_preference.py | 88 ++++++ app/router/private/user.py | 260 +++++++++++++++++- app/utils.py | 27 ++ ...6_fa4952dc70df_user_add_user_preference.py | 66 +++++ 6 files changed, 459 insertions(+), 17 deletions(-) create mode 100644 app/database/user_preference.py create mode 100644 migrations/versions/2025-10-06_fa4952dc70df_user_add_user_preference.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 079112f..11a60ee 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -68,6 +68,7 @@ from .user_account_history import ( UserAccountHistoryType, ) from .user_login_log import UserLoginLog +from .user_preference import UserPreference from .verification import EmailVerification, LoginSession, LoginSessionResp, TrustedDevice, TrustedDeviceResp __all__ = [ @@ -143,6 +144,7 @@ __all__ = [ "UserAchievementResp", "UserLoginLog", "UserNotification", + "UserPreference", "UserResp", "UserStatistics", "UserStatisticsResp", diff --git a/app/database/user.py b/app/database/user.py index 5c13af4..2474d31 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -3,7 +3,6 @@ import json from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, overload from app.config import settings -from app.database.auth import TotpKeys from app.models.model import UTCBaseModel from app.models.score import GameMode from app.models.user import Country, Page @@ -11,6 +10,7 @@ from app.path import STATIC_DIR from app.utils import utcnow from .achievement import UserAchievement, UserAchievementResp +from .auth import TotpKeys from .beatmap_playcounts import BeatmapPlaycounts from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp @@ -19,6 +19,7 @@ from .rank_history import RankHistory, RankHistoryResp, RankTop from .statistics import UserStatistics, UserStatisticsResp from .team import Team, TeamMember from .user_account_history import UserAccountHistory, UserAccountHistoryResp, UserAccountHistoryType +from .user_preference import DEFAULT_ORDER, UserPreference from pydantic import field_validator from sqlalchemy.ext.asyncio import AsyncAttrs @@ -112,18 +113,6 @@ class UserBase(UTCBaseModel, SQLModel): playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON)) # TODO: post_count profile_hue: int | None = None - profile_order: list[str] = Field( - default_factory=lambda: [ - "me", - "recent_activity", - "top_ranks", - "medals", - "historical", - "beatmaps", - "kudosu", - ], - sa_column=Column(JSON), - ) title: str | None = None title_url: str | None = None twitter: str | None = None @@ -137,6 +126,9 @@ class UserBase(UTCBaseModel, SQLModel): is_qat: bool = False is_bng: bool = False + # g0v0-extra + g0v0_playmode: GameMode = GameMode.OSU + @field_validator("playmode", mode="before") @classmethod def validate_playmode(cls, v): @@ -170,6 +162,7 @@ class User(AsyncAttrs, UserBase, table=True): ) events: list[Event] = Relationship(back_populates="user") totp_key: TotpKeys | None = Relationship(back_populates="user") + user_preference: UserPreference | None = Relationship(back_populates="user") email: str = Field(max_length=254, unique=True, index=True, exclude=True) priv: int = Field(default=1, exclude=True) @@ -284,8 +277,12 @@ class UserResp(UserBase): default_group: str = "" is_deleted: bool = False # TODO is_restricted: bool = False + user_preference: UserPreference | None = None + profile_order: list[str] = Field( + default_factory=lambda: DEFAULT_ORDER, + ) - # TODO: monthly_playcounts, unread_pm_count, rank_history, user_preferences + # TODO: unread_pm_count @classmethod async def from_db( @@ -333,6 +330,13 @@ class UserResp(UserBase): u.is_online = bool(await redis.exists(f"metadata:online:{obj.id}")) u.cover_url = obj.cover.get("url", "") if obj.cover else "" + await obj.awaitable_attrs.user_preference + if obj.user_preference: + u.profile_order = obj.user_preference.extras_order + + if "user_preference" in include: + u.user_preference = obj.user_preference + if "friends" in include: u.friends = [ await RelationshipResp.from_db(session, r) @@ -510,6 +514,7 @@ ALL_INCLUDED = [ "rank_history", "is_restricted", "session_verified", + "user_preference", ] diff --git a/app/database/user_preference.py b/app/database/user_preference.py new file mode 100644 index 0000000..991ead6 --- /dev/null +++ b/app/database/user_preference.py @@ -0,0 +1,88 @@ +from enum import Enum +from typing import TYPE_CHECKING, Any + +from sqlmodel import JSON, BigInteger, Column, Field, ForeignKey, Relationship, SQLModel + +if TYPE_CHECKING: + from .user import User + +DEFAULT_ORDER = [ + "me", + "recent_activity", + "top_ranks", + "medals", + "historical", + "beatmaps", + "kudosu", +] + + +class BeatmapCardSize(str, Enum): + NORMAL = "normal" + EXTRA = "extra" + + +class BeatmapDownload(str, Enum): + ALL = "all" + NO_VIDEO = "no_video" + direct = "direct" + + +class ScoringMode(str, Enum): + STANDARDISED = "standardised" + CLASSIC = "classic" + + +class UserListFilter(str, Enum): + ALL = "all" + ONLINE = "online" + OFFLINE = "offline" + + +class UserListSort(str, Enum): + LAST_VISIT = "last_visit" + RANK = "rank" + USERNAME = "username" + + +class UserListView(str, Enum): + CARD = "card" + LIST = "list" + BRICK = "brick" + + +class UserPreference(SQLModel, table=True): + user_id: int = Field( + exclude=True, sa_column=Column(BigInteger, ForeignKey("lazer_users.id", ondelete="CASCADE"), primary_key=True) + ) + + theme: str = "light" + # refer to https://github.com/ppy/osu/blob/30fd40efd16a651a6c00b5c89289a85ffcbe546b/osu.Game/Localisation/Language.cs + # zh_hant -> zh-tw + language: str = "en" + extra: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + # https://github.com/ppy/osu-web/blob/cae2fdf03cfb8c30c8e332cfb142e03188ceffef/app/Models/UserProfileCustomization.php#L20-L38 + audio_autoplay: bool = False + audio_muted: bool = False + audio_volume: float = 0.45 + beatmapset_card_size: BeatmapCardSize = BeatmapCardSize.NORMAL + beatmap_download: BeatmapDownload = BeatmapDownload.ALL + beatmapset_show_nsfw: bool = False + + # comments_show_deleted: bool = False + # forum_posts_show_deleted: bool = False + + extras_order: list[str] = Field( + default_factory=lambda: DEFAULT_ORDER, + sa_column=Column(JSON), + exclude=True, + ) + legacy_score_only: bool = False # lazer mode + profile_cover_expanded: bool = True + scoring_mode: ScoringMode = ScoringMode.STANDARDISED + user_list_filter: UserListFilter = UserListFilter.ALL + user_list_sort: UserListSort = UserListSort.LAST_VISIT + user_list_view: UserListView = UserListView.CARD + + user: "User" = Relationship(back_populates="user_preference") diff --git a/app/router/private/user.py b/app/router/private/user.py index a44b1f0..5944df4 100644 --- a/app/router/private/user.py +++ b/app/router/private/user.py @@ -1,11 +1,22 @@ -from typing import Annotated +from typing import Annotated, Any from app.auth import validate_username from app.config import settings from app.database import User from app.database.events import Event, EventType -from app.dependencies.database import Database +from app.database.user_preference import ( + DEFAULT_ORDER, + BeatmapCardSize, + BeatmapDownload, + ScoringMode, + UserListFilter, + UserListSort, + UserListView, + UserPreference, +) +from app.dependencies.database import Database, Redis from app.dependencies.user import ClientUser +from app.models.score import GameMode from app.models.user import Page from app.models.userpage import ( UpdateUserpageRequest, @@ -15,11 +26,13 @@ from app.models.userpage import ( ValidateBBCodeResponse, ) from app.service.bbcode_service import bbcode_service -from app.utils import utcnow +from app.service.user_cache_service import get_user_cache_service +from app.utils import hex_to_hue, utcnow from .router import router from fastapi import Body, HTTPException +from pydantic import BaseModel, Field from sqlmodel import exists, select @@ -134,3 +147,244 @@ async def validate_bbcode( return ValidateBBCodeResponse(valid=False, errors=[e.message], preview={"raw": request.content, "html": ""}) except Exception: raise HTTPException(status_code=500, detail={"error": "Failed to validate BBCode"}) + + +class Preferences(BaseModel): + theme: str | None = None + language: str | None = None + + audio_autoplay: bool | None = None + audio_muted: bool | None = None + audio_volume: float | None = Field(None, ge=0.0, le=1.0) + beatmapset_card_size: BeatmapCardSize | None = None + beatmap_download: BeatmapDownload | None = None + beatmapset_show_nsfw: bool | None = None + profile_order: list[str] | None = None + legacy_score_only: bool | None = None + profile_cover_expanded: bool | None = None + scoring_mode: ScoringMode | None = None + user_list_filter: UserListFilter | None = None + user_list_sort: UserListSort | None = None + user_list_view: UserListView | None = None + extra: dict[str, Any] | None = None + + # in User + playmode: GameMode | None = None + interests: str | None = None + location: str | None = None + occupation: str | None = None + twitter: str | None = None + website: str | None = None + discord: str | None = None + profile_colour: str | None = None + + @staticmethod + async def clear(current_user: User, fields: list[str]): + await current_user.awaitable_attrs.user_preference + user_pref: UserPreference | None = current_user.user_preference + if user_pref is None: + return + if len(fields) == 0: + fields = [ + *PREFERENCE_FIELDS, + *USER_PROFILE_FIELDS_WITH_WEBSITE, + "profile_order", + "extra", + "playmode", + "profile_colour", + ] + + for field in fields: + if field in PREFERENCE_FIELDS: + setattr(user_pref, field, UserPreference.model_fields[field].default) + elif field == "profile_order": + user_pref.extras_order = DEFAULT_ORDER + elif field == "extra": + user_pref.extra = {} + + for field in fields: + if field in USER_PROFILE_FIELDS_WITH_WEBSITE: + setattr(current_user, field, None) + elif field == "playmode": + current_user.playmode = GameMode.OSU + current_user.g0v0_playmode = GameMode.OSU + elif field == "profile_colour": + current_user.profile_colour = None + current_user.profile_hue = None + + +PREFERENCE_FIELDS = { + "theme", + "language", + "audio_autoplay", + "audio_muted", + "audio_volume", + "beatmapset_card_size", + "beatmap_download", + "beatmapset_show_nsfw", + "legacy_score_only", + "profile_cover_expanded", + "scoring_mode", + "user_list_filter", + "user_list_sort", + "user_list_view", +} + +USER_PROFILE_FIELDS = { + "interests", + "location", + "occupation", + "twitter", + "discord", +} + +USER_PROFILE_FIELDS_WITH_WEBSITE = USER_PROFILE_FIELDS | {"website"} + + +@router.get( + "/user/preferences", + name="获取用户偏好设置", + description="获取当前登录用户的偏好设置", + tags=["用户", "g0v0 API"], + response_model=Preferences, +) +async def get_user_preference( + current_user: ClientUser, +): + await current_user.awaitable_attrs.user_preference + user_pref: UserPreference | None = current_user.user_preference + if user_pref is None: + user_pref = UserPreference(user_id=current_user.id) + + return Preferences( + theme=user_pref.theme, + language=user_pref.language, + audio_autoplay=user_pref.audio_autoplay, + audio_muted=user_pref.audio_muted, + audio_volume=user_pref.audio_volume, + beatmapset_card_size=user_pref.beatmapset_card_size, + beatmap_download=user_pref.beatmap_download, + beatmapset_show_nsfw=user_pref.beatmapset_show_nsfw, + profile_order=user_pref.extras_order or DEFAULT_ORDER, + legacy_score_only=user_pref.legacy_score_only, + profile_cover_expanded=user_pref.profile_cover_expanded, + scoring_mode=user_pref.scoring_mode, + user_list_filter=user_pref.user_list_filter, + user_list_sort=user_pref.user_list_sort, + user_list_view=user_pref.user_list_view, + extra=user_pref.extra or {}, + playmode=current_user.g0v0_playmode, + interests=current_user.interests, + location=current_user.location, + occupation=current_user.occupation, + twitter=current_user.twitter, + website=current_user.website, + discord=current_user.discord, + profile_colour="#" + current_user.profile_colour if current_user.profile_colour else None, + ) + + +@router.patch( + "/user/preferences", + name="修改用户偏好设置", + description="修改当前登录用户的偏好设置", + tags=["用户", "g0v0 API"], + status_code=204, +) +async def change_user_preference( + request: Preferences, + session: Database, + current_user: ClientUser, + redis: Redis, +): + if await current_user.is_restricted(session): + raise HTTPException(403, "Your account is restricted and cannot perform this action.") + + cache_service = get_user_cache_service(redis) + + await current_user.awaitable_attrs.user_preference + user_pref: UserPreference | None = current_user.user_preference + if user_pref is None: + user_pref = UserPreference(user_id=current_user.id) + session.add(user_pref) + + for field, value in request.model_dump(include=PREFERENCE_FIELDS, exclude_none=True).items(): + setattr(user_pref, field, value) + + if request.profile_order is not None: + if set(request.profile_order) != set(DEFAULT_ORDER): + raise HTTPException(400, "Invalid profile order") + user_pref.extras_order = request.profile_order + + if request.extra is not None: + user_pref.extra = (user_pref.extra or {}) | request.extra + + if request.playmode is not None: + current_user.playmode = request.playmode.to_base_ruleset() + current_user.g0v0_playmode = request.playmode + + for field, value in request.model_dump(include=USER_PROFILE_FIELDS, exclude_none=True).items(): + setattr(current_user, field, value or None) + + if request.website is not None: + if request.website == "": + current_user.website = None + elif not (request.website.startswith("http://") or request.website.startswith("https://")): + current_user.website = "https://" + request.website + + if request.profile_colour is not None: + current_user.profile_colour = request.profile_colour.removeprefix("#") + try: + current_user.profile_hue = hex_to_hue(request.profile_colour) + except ValueError: + raise HTTPException(400, "Invalid profile colour hex value") + + await cache_service.invalidate_user_cache(current_user.id) + await session.commit() + + +@router.put( + "/user/preferences", + name="覆盖用户偏好设置", + description="使用提供的数据完整覆盖当前登录用户的偏好设置,未提供的字段将被重置为默认值。", + tags=["用户", "g0v0 API"], + status_code=204, +) +async def overwrite_user_preference( + request: Preferences, + session: Database, + current_user: ClientUser, + redis: Redis, +): + if await current_user.is_restricted(session): + raise HTTPException(403, "Your account is restricted and cannot perform this action.") + + await Preferences.clear(current_user, []) + await change_user_preference(request, session, current_user, redis) + + cache_service = get_user_cache_service(redis) + await cache_service.invalidate_user_cache(current_user.id) + await session.commit() + + +@router.delete( + "/user/preferences", + name="删除用户偏好设置", + description="删除当前登录用户的偏好设置,恢复为默认值\n\n如果未指定字段,则删除所有可删除的偏好设置", + tags=["用户", "g0v0 API"], + status_code=204, +) +async def delete_user_preference( + session: Database, + current_user: ClientUser, + fields: list[str], + redis: Redis, +): + if await current_user.is_restricted(session): + raise HTTPException(403, "Your account is restricted and cannot perform this action.") + + await Preferences.clear(current_user, fields) + + cache_service = get_user_cache_service(redis) + await cache_service.invalidate_user_cache(current_user.id) + await session.commit() diff --git a/app/utils.py b/app/utils.py index fce1e87..c786511 100644 --- a/app/utils.py +++ b/app/utils.py @@ -272,3 +272,30 @@ bg_tasks = BackgroundTasks() def utcnow() -> datetime: return datetime.now(tz=UTC) + + +def hex_to_hue(hex_color: str) -> int: + """Convert a hex color string to a hue value (0-360).""" + hex_color = hex_color.lstrip("#") + if len(hex_color) != 6: + raise ValueError("Invalid hex color format. Expected format: RRGGBB") + + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + + max_c = max(r, g, b) + min_c = min(r, g, b) + delta = max_c - min_c + + if delta == 0: + return 0 # Achromatic (grey) + + if max_c == r: + hue = (60 * ((g - b) / delta) + 360) % 360 + elif max_c == g: + hue = (60 * ((b - r) / delta) + 120) % 360 + else: # max_c == b + hue = (60 * ((r - g) / delta) + 240) % 360 + + return int(hue) diff --git a/migrations/versions/2025-10-06_fa4952dc70df_user_add_user_preference.py b/migrations/versions/2025-10-06_fa4952dc70df_user_add_user_preference.py new file mode 100644 index 0000000..8402e1f --- /dev/null +++ b/migrations/versions/2025-10-06_fa4952dc70df_user_add_user_preference.py @@ -0,0 +1,66 @@ +"""user: add user_preference + +Revision ID: fa4952dc70df +Revises: 425b91532cb4 +Create Date: 2025-10-06 04:13:47.131043 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "fa4952dc70df" +down_revision: str | Sequence[str] | None = "425b91532cb4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "userpreference", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("theme", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("language", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("extra", sa.JSON(), nullable=True), + sa.Column("audio_autoplay", sa.Boolean(), nullable=False), + sa.Column("audio_muted", sa.Boolean(), nullable=False), + sa.Column("audio_volume", sa.Float(), nullable=False), + sa.Column("beatmapset_card_size", sa.Enum("NORMAL", "EXTRA", name="beatmapcardsize"), nullable=False), + sa.Column("beatmap_download", sa.Enum("ALL", "NO_VIDEO", "direct", name="beatmapdownload"), nullable=False), + sa.Column("beatmapset_show_nsfw", sa.Boolean(), nullable=False), + sa.Column("extras_order", sa.JSON(), nullable=True), + sa.Column("legacy_score_only", sa.Boolean(), nullable=False), + sa.Column("profile_cover_expanded", sa.Boolean(), nullable=False), + sa.Column("scoring_mode", sa.Enum("STANDARDISED", "CLASSIC", name="scoringmode"), nullable=False), + sa.Column("user_list_filter", sa.Enum("ALL", "ONLINE", "OFFLINE", name="userlistfilter"), nullable=False), + sa.Column("user_list_sort", sa.Enum("LAST_VISIT", "RANK", "USERNAME", name="userlistsort"), nullable=False), + sa.Column("user_list_view", sa.Enum("CARD", "LIST", "BRICK", name="userlistview"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["lazer_users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + op.add_column( + "lazer_users", + sa.Column( + "g0v0_playmode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX", name="gamemode"), + nullable=False, + ), + ) + op.drop_column("lazer_users", "profile_order") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("lazer_users", sa.Column("profile_order", mysql.JSON(), nullable=True)) + op.drop_column("lazer_users", "g0v0_playmode") + op.drop_table("userpreference") + # ### end Alembic commands ###