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, UserAccountHistoryType,
) )
from .user_login_log import UserLoginLog from .user_login_log import UserLoginLog
from .user_preference import UserPreference
from .verification import EmailVerification, LoginSession, LoginSessionResp, TrustedDevice, TrustedDeviceResp from .verification import EmailVerification, LoginSession, LoginSessionResp, TrustedDevice, TrustedDeviceResp
__all__ = [ __all__ = [
@@ -143,6 +144,7 @@ __all__ = [
"UserAchievementResp", "UserAchievementResp",
"UserLoginLog", "UserLoginLog",
"UserNotification", "UserNotification",
"UserPreference",
"UserResp", "UserResp",
"UserStatistics", "UserStatistics",
"UserStatisticsResp", "UserStatisticsResp",

View File

@@ -3,7 +3,6 @@ import json
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, overload from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, overload
from app.config import settings from app.config import settings
from app.database.auth import TotpKeys
from app.models.model import UTCBaseModel from app.models.model import UTCBaseModel
from app.models.score import GameMode from app.models.score import GameMode
from app.models.user import Country, Page from app.models.user import Country, Page
@@ -11,6 +10,7 @@ from app.path import STATIC_DIR
from app.utils import utcnow from app.utils import utcnow
from .achievement import UserAchievement, UserAchievementResp from .achievement import UserAchievement, UserAchievementResp
from .auth import TotpKeys
from .beatmap_playcounts import BeatmapPlaycounts from .beatmap_playcounts import BeatmapPlaycounts
from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
@@ -19,6 +19,7 @@ from .rank_history import RankHistory, RankHistoryResp, RankTop
from .statistics import UserStatistics, UserStatisticsResp from .statistics import UserStatistics, UserStatisticsResp
from .team import Team, TeamMember from .team import Team, TeamMember
from .user_account_history import UserAccountHistory, UserAccountHistoryResp, UserAccountHistoryType from .user_account_history import UserAccountHistory, UserAccountHistoryResp, UserAccountHistoryType
from .user_preference import DEFAULT_ORDER, UserPreference
from pydantic import field_validator from pydantic import field_validator
from sqlalchemy.ext.asyncio import AsyncAttrs 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)) playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: post_count # TODO: post_count
profile_hue: int | None = None 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: str | None = None
title_url: str | None = None title_url: str | None = None
twitter: str | None = None twitter: str | None = None
@@ -137,6 +126,9 @@ class UserBase(UTCBaseModel, SQLModel):
is_qat: bool = False is_qat: bool = False
is_bng: bool = False is_bng: bool = False
# g0v0-extra
g0v0_playmode: GameMode = GameMode.OSU
@field_validator("playmode", mode="before") @field_validator("playmode", mode="before")
@classmethod @classmethod
def validate_playmode(cls, v): def validate_playmode(cls, v):
@@ -170,6 +162,7 @@ class User(AsyncAttrs, UserBase, table=True):
) )
events: list[Event] = Relationship(back_populates="user") events: list[Event] = Relationship(back_populates="user")
totp_key: TotpKeys | None = 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) email: str = Field(max_length=254, unique=True, index=True, exclude=True)
priv: int = Field(default=1, exclude=True) priv: int = Field(default=1, exclude=True)
@@ -284,8 +277,12 @@ class UserResp(UserBase):
default_group: str = "" default_group: str = ""
is_deleted: bool = False # TODO is_deleted: bool = False # TODO
is_restricted: bool = False 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 @classmethod
async def from_db( async def from_db(
@@ -333,6 +330,13 @@ class UserResp(UserBase):
u.is_online = bool(await redis.exists(f"metadata:online:{obj.id}")) u.is_online = bool(await redis.exists(f"metadata:online:{obj.id}"))
u.cover_url = obj.cover.get("url", "") if obj.cover else "" 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: if "friends" in include:
u.friends = [ u.friends = [
await RelationshipResp.from_db(session, r) await RelationshipResp.from_db(session, r)
@@ -510,6 +514,7 @@ ALL_INCLUDED = [
"rank_history", "rank_history",
"is_restricted", "is_restricted",
"session_verified", "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")

View File

@@ -1,11 +1,22 @@
from typing import Annotated from typing import Annotated, Any
from app.auth import validate_username from app.auth import validate_username
from app.config import settings from app.config import settings
from app.database import User from app.database import User
from app.database.events import Event, EventType 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.dependencies.user import ClientUser
from app.models.score import GameMode
from app.models.user import Page from app.models.user import Page
from app.models.userpage import ( from app.models.userpage import (
UpdateUserpageRequest, UpdateUserpageRequest,
@@ -15,11 +26,13 @@ from app.models.userpage import (
ValidateBBCodeResponse, ValidateBBCodeResponse,
) )
from app.service.bbcode_service import bbcode_service 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 .router import router
from fastapi import Body, HTTPException from fastapi import Body, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import exists, select 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": ""}) return ValidateBBCodeResponse(valid=False, errors=[e.message], preview={"raw": request.content, "html": ""})
except Exception: except Exception:
raise HTTPException(status_code=500, detail={"error": "Failed to validate BBCode"}) 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()

View File

@@ -272,3 +272,30 @@ bg_tasks = BackgroundTasks()
def utcnow() -> datetime: def utcnow() -> datetime:
return datetime.now(tz=UTC) 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)

View File

@@ -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 ###