feat(score): support pin score

This commit is contained in:
MingxuanGame
2025-08-10 15:36:39 +00:00
parent a087b0de2e
commit 32e2ac5704
5 changed files with 288 additions and 4 deletions

View File

@@ -15,7 +15,12 @@ from app.calculator import (
)
from app.config import settings
from app.database.team import TeamMember
from app.models.model import RespWithCursor, UTCBaseModel
from app.models.model import (
CurrentUserAttributes,
PinAttributes,
RespWithCursor,
UTCBaseModel,
)
from app.models.mods import APIMod, mods_can_get_pp
from app.models.score import (
INT_TO_MODE,
@@ -122,6 +127,7 @@ class Score(ScoreBase, table=True):
nslider_tail_hit: int | None = Field(default=None, exclude=True)
nsmall_tick_hit: int | None = Field(default=None, exclude=True)
gamemode: GameMode = Field(index=True)
pinned_order: int = Field(default=0, exclude=True)
# optional
beatmap: Beatmap = Relationship()
@@ -166,6 +172,7 @@ class ScoreResp(ScoreBase):
rank_country: int | None = None
position: int | None = None
scores_around: "ScoreAround | None" = None
current_user_attributes: CurrentUserAttributes | None = None
@classmethod
async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp":
@@ -234,6 +241,9 @@ class ScoreResp(ScoreBase):
)
or None
)
s.current_user_attributes = CurrentUserAttributes(
pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id)
)
return s

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime
from app.models.score import GameMode
from pydantic import BaseModel, field_serializer
@@ -20,3 +22,34 @@ Cursor = dict[str, int]
class RespWithCursor(BaseModel):
cursor: Cursor | None = None
class PinAttributes(BaseModel):
is_pinned: bool
score_id: int
class CurrentUserAttributes(BaseModel):
can_beatmap_update_owner: bool | None = None
can_delete: bool | None = None
can_edit_metadata: bool | None = None
can_edit_tags: bool | None = None
can_hype: bool | None = None
can_hype_reason: str | None = None
can_love: bool | None = None
can_remove_from_loved: bool | None = None
is_watching: bool | None = None
new_hype_time: datetime | None = None
nomination_modes: list[GameMode] | None = None
remaining_hype: int | None = None
can_destroy: bool | None = None
can_reopen: bool | None = None
can_moderate_kudosu: bool | None = None
can_resolve: bool | None = None
vote_score: int | None = None
can_message: bool | None = None
can_message_error: str | None = None
last_read_id: int | None = None
can_new_comment: bool | None = None
can_new_comment_reason: str | None = None
pin: PinAttributes | None = None

View File

@@ -43,12 +43,12 @@ from app.models.score import (
from .api_router import router
from fastapi import Depends, Form, HTTPException, Query
from fastapi import Body, Depends, Form, HTTPException, Query
from httpx import HTTPError
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlalchemy.orm import joinedload
from sqlmodel import col, select
from sqlmodel import col, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
READ_SCORE_TIMEOUT = 10
@@ -548,3 +548,158 @@ async def get_user_playlist_score(
room_id, playlist_id, score_record.score_id, session
)
return resp
@router.put("/score-pins/{score}", status_code=204)
async def pin_score(
score: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(
Score.id == score,
Score.user_id == current_user.id,
col(Score.passed).is_(True),
)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order > 0:
return
next_order = (
(
await db.exec(
select(func.max(Score.pinned_order)).where(
Score.user_id == current_user.id,
Score.gamemode == score_record.gamemode,
)
)
).first()
or 0
) + 1
score_record.pinned_order = next_order
await db.commit()
@router.delete("/score-pins/{score}", status_code=204)
async def unpin_score(
score: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(Score.id == score, Score.user_id == current_user.id)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order == 0:
return
changed_score = (
await db.exec(
select(Score).where(
Score.user_id == current_user.id,
Score.pinned_order > score_record.pinned_order,
Score.gamemode == score_record.gamemode,
)
)
).all()
for s in changed_score:
s.pinned_order -= 1
await db.commit()
@router.post("/score-pins/{score}/reorder", status_code=204)
async def reorder_score_pin(
score: int,
after_score_id: int | None = Body(default=None),
before_score_id: int | None = Body(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(Score.id == score, Score.user_id == current_user.id)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order == 0:
raise HTTPException(status_code=400, detail="Score is not pinned")
if (after_score_id is None) == (before_score_id is None):
raise HTTPException(
status_code=400,
detail="Either after_score_id or before_score_id "
"must be provided (but not both)",
)
all_pinned_scores = (
await db.exec(
select(Score)
.where(
Score.user_id == current_user.id,
Score.pinned_order > 0,
Score.gamemode == score_record.gamemode,
)
.order_by(col(Score.pinned_order))
)
).all()
target_order = None
reference_score_id = after_score_id or before_score_id
reference_score = next(
(s for s in all_pinned_scores if s.id == reference_score_id), None
)
if not reference_score:
detail = "After score not found" if after_score_id else "Before score not found"
raise HTTPException(status_code=404, detail=detail)
if after_score_id:
target_order = reference_score.pinned_order + 1
else:
target_order = reference_score.pinned_order
current_order = score_record.pinned_order
if current_order == target_order:
return
updates = []
if current_order < target_order:
for s in all_pinned_scores:
if current_order < s.pinned_order <= target_order and s.id != score:
updates.append((s.id, s.pinned_order - 1))
if after_score_id:
final_target = (
target_order - 1 if target_order > current_order else target_order
)
else:
final_target = target_order
else:
for s in all_pinned_scores:
if target_order <= s.pinned_order < current_order and s.id != score:
updates.append((s.id, s.pinned_order + 1))
final_target = target_order
for score_id, new_order in updates:
await db.exec(select(Score).where(Score.id == score_id))
score_to_update = (
await db.exec(select(Score).where(Score.id == score_id))
).first()
if score_to_update:
score_to_update.pinned_order = new_order
score_record.pinned_order = final_target
await db.commit()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
from app.database import (
BeatmapPlaycounts,
BeatmapPlaycountsResp,
@@ -8,6 +10,7 @@ from app.database import (
UserResp,
)
from app.database.lazer_user import SEARCH_INCLUDED
from app.database.score import Score, ScoreResp
from app.dependencies.database import get_db
from app.dependencies.user import get_current_user
from app.models.score import GameMode
@@ -17,7 +20,7 @@ from .api_router import router
from fastapi import Depends, HTTPException, Query
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel import false, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import col
@@ -130,3 +133,52 @@ async def get_user_beatmapsets(
raise HTTPException(400, detail="Invalid beatmapset type")
return resp
@router.get("/users/{user}/scores/{type}", response_model=list[ScoreResp])
async def get_user_scores(
user: int,
type: Literal["best", "recent", "firsts", "pinned"],
legacy_only: bool = Query(False),
include_fails: bool = Query(False),
mode: GameMode | None = None,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_db),
):
db_user = await session.get(User, user)
if not db_user:
raise HTTPException(404, detail="User not found")
gamemode = mode or db_user.playmode
order_by = None
where_clause = (
(col(Score.user_id) == db_user.id)
& (col(Score.gamemode) == gamemode)
& (col(Score.passed).is_(True))
)
if type == "pinned":
where_clause &= Score.pinned_order > 0
order_by = col(Score.pinned_order).asc()
else:
# TODO
where_clause &= false()
scores = (
await session.exec(
select(Score)
.where(where_clause)
.order_by(order_by)
.limit(limit)
.offset(offset)
)
).all()
if not scores:
return []
return [
await ScoreResp.from_db(
session,
score,
)
for score in scores
]

View File

@@ -0,0 +1,34 @@
"""score: support pin score
Revision ID: 319e5f841dcf
Revises: 19cdc9ce4dcb
Create Date: 2025-08-10 14:07:51.749025
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "319e5f841dcf"
down_revision: str | Sequence[str] | None = "19cdc9ce4dcb"
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.add_column("scores", sa.Column("pinned_order", sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("scores", "pinned_order")
# ### end Alembic commands ###