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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
app/database/user_preference.py
Normal file
88
app/database/user_preference.py
Normal 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")
|
||||||
@@ -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()
|
||||||
|
|||||||
27
app/utils.py
27
app/utils.py
@@ -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)
|
||||||
|
|||||||
@@ -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 ###
|
||||||
Reference in New Issue
Block a user