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>
This commit is contained in:
MingxuanGame
2025-10-06 20:57:17 +08:00
committed by GitHub
parent febc1d761f
commit 10caa82320
6 changed files with 459 additions and 17 deletions

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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")